support.eth curator

Building Custom Clients

How to build custom frontends, CLIs, bots, and agents on top of Curator Studio

Building Custom Clients

Curator Studio is designed as composable infrastructure. The contracts are permissionless, the indexer exposes a standard GraphQL/REST API, and the SDK works in any JavaScript runtime — not just React apps.

The Stack

┌─────────────────────────────────────────────────────────────────┐
│                           CLIENTS                               │
│                                                                 │
│   React apps          ──▶  CuratorProvider + hooks             │
│   Other web apps      ──▶  CuratorSDK class                    │
│   CLI tools           ──▶  CuratorSDK class                    │
│   AI agents / bots    ──▶  CuratorSDK class                    │
│   Keepers / cron      ──▶  CuratorSDK class                    │
│   Chat bots           ──▶  CuratorSDK class                    │
│   Other protocols     ──▶  contracts directly                  │
├─────────────────────────────────────────────────────────────────┤
│                     @curator-studio/sdk                         │
│                                                                 │
│   React layer    CuratorProvider, hooks (optional, needs React) │
│   Core layer     CuratorSDK class, indexer client (pure TS)     │
├─────────────────────────────────────────────────────────────────┤
│                     @curator-studio/indexer                     │
│                                                                 │
│   GraphQL          /graphql — strategies, distributions, etc.   │
│   REST (analytics) /api/strategies/trending, /api/stats         │
│   REST (social)    /api/drafts, /api/comments, /api/reactions   │
│   REST (notifs)    /api/notifications (authenticated)           │
├─────────────────────────────────────────────────────────────────┤
│                     ON-CHAIN CONTRACTS                          │
│                                                                 │
│   StrategyFactory          Deploys strategy clones (per tenant) │
│   Strategy                 Allocations, distribute, rebalance   │
│   SplitsWarehouse          ERC-6909 vault, withdraw (shared)    │
│   YieldRedirector4626      Wraps ERC-4626, redirects yield      │
│   ForeverSubnameRegistrar  *.tenant.eth ENS subdomains          │
└─────────────────────────────────────────────────────────────────┘

What You Need

Install the SDK and provide a tenant name. Contract addresses, ABIs, and RPC are handled automatically.

npm install @curator-studio/sdk
import { CuratorSDK } from "@curator-studio/sdk";

const sdk = new CuratorSDK(walletClient, { tenant: "support.eth" });

The indexer is hosted at https://curatefund-multi-tenant-production.up.railway.app (GraphQL at /graphql, REST at /api/*). Pass it explicitly if you need indexer queries:

const sdk = new CuratorSDK(walletClient, {
  tenant: "support.eth",
  indexerUrl: "https://curatefund-multi-tenant-production.up.railway.app",
});

SDK Layers

The SDK has two layers — non-React clients are first-class.

Core layerCuratorSDK class. Pure TypeScript, no framework dependency. Works in Node.js, Deno, Bun, browsers, serverless functions.

React layerCuratorProvider + hooks. Wraps the core layer with React context and TanStack Query. Optional — only needed for React apps.

Client Types

React App

Wrap your app with CuratorProvider, then use hooks:

import { CuratorProvider, useStrategies } from "@curator-studio/sdk/react";

function App() {
  return (
    <CuratorProvider
      tenant="support.eth"
      indexerUrl="https://curatefund-multi-tenant-production.up.railway.app"
    >
      <StrategyList />
    </CuratorProvider>
  );
}

function StrategyList() {
  const { data, isPending } = useStrategies({
    orderBy: "timesForked",
    orderDirection: "desc",
    limit: 10,
  });
  if (isPending) return <div>Loading...</div>;
  return data?.items.map((s) => <div key={s.id}>{s.metadata?.title}</div>);
}

CLI, Script, or Non-React App

The CuratorSDK class works anywhere — Node.js, Deno, Bun, Vue, Svelte, etc.:

import { CuratorSDK } from "@curator-studio/sdk";
import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { baseSepolia } from "viem/chains";

const wallet = createWalletClient({
  account: privateKeyToAccount(process.env.PRIVATE_KEY),
  chain: baseSepolia,
  transport: http(),
});

const sdk = new CuratorSDK(wallet, {
  tenant: "support.eth",
  indexerUrl: "https://curatefund-multi-tenant-production.up.railway.app",
});

const { items } = await sdk.indexer.strategy.query({ limit: 5 });
await sdk.strategy.distribute(items[0].id, tokenAddress);

Keeper / Cron Job

A service that periodically distributes or harvests. Since distribute() and harvest() are permissionless, anyone can call them:

for (const strategy of strategies.items) {
  const balance = await sdk.strategy.balanceOf(strategy.id, tokenAddress);
  if (balance > threshold) {
    await sdk.strategy.distribute(strategy.id, tokenAddress);
  }
}

AI Agent or Bot

The SDK works as a tool for autonomous agents — MCP servers, LangChain tools, Eliza plugins, etc.:

const stats = await sdk.indexer.stats();
const trending = await sdk.indexer.trending({ period: "7d", limit: 5 });

await sdk.strategy.create({
  owner: wallet.account.address,
  allocations: [
    { recipient: "0x...", weight: 60n, label: "Top project" },
    { recipient: "0x...", weight: 40n, label: "Runner up" },
  ],
  metadataURI: "https://...",
  sourceStrategy: "0x0000000000000000000000000000000000000000",
});

Direct Integration (No SDK)

Query the indexer GraphQL endpoint directly and call contracts via viem or ethers. Addresses and ABIs are in @curator-studio/contracts/deployments.json.

query {
  strategys(where: { tenantId: "support.eth" }, limit: 10) {
    items {
      id
      owner
      metadataURI
      totalWeight
    }
  }
}

REST API (Social Layer)

The indexer serves a REST API for the social layer: drafts, comments, reactions, and notifications. The same process hosts both GraphQL and REST, so the base URL is the same.

Authentication

Write operations require a JWT obtained via the BetterAuth SIWE (Sign-In with Ethereum) flow. Pass it as a Bearer token:

const response = await fetch(
  "https://curatefund-multi-tenant-production.up.railway.app/api/drafts",
  {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${jwt}`,
    },
    body: JSON.stringify({
      title: "My Strategy Draft",
      description: "A new allocation strategy",
      allocations: [
        { recipient: "0xabc...", weight: 60, label: "Project A" },
        { recipient: "0xdef...", weight: 40, label: "Project B" },
      ],
    }),
  },
);

const { data } = await response.json();

Read endpoints (listing drafts, comments, reactions) are public — no auth header needed.

Version Endpoints

MethodPathAuthDescription
GET/api/versionsNoList versions (query: draftId required)
GET/api/versions/:idNoGet a single version by ID

Curator Endpoints

MethodPathAuthDescription
GET/api/curators/:address/statsNoGet curator stats (strategies, drafts, forks)

Draft Endpoints

MethodPathAuthDescription
GET/api/draftsNoList drafts (query: curator, sourceStrategy, sourceDraft, proposalsFor, cursor, limit)
POST/api/draftsYesCreate a draft
GET/api/drafts/:idNoGet a single draft
PUT/api/drafts/:idYesUpdate title, description, or allocations
DELETE/api/drafts/:idYesSoft-delete a draft
PUT/api/drafts/:id/visibilityYesSet visibility (private, unlisted, public)
POST/api/drafts/forkYesFork a strategy or draft (auto-proposes merge)
POST/api/drafts/:id/publishYesPublish draft on-chain
POST/api/drafts/:id/propose-mergeYesPropose merging a fork into its parent
POST/api/drafts/:id/accept-mergeYesAccept a merge proposal (parent owner)
POST/api/drafts/:id/reject-mergeYesReject a merge proposal (parent owner)
POST/api/drafts/:id/withdraw-mergeYesWithdraw a merge proposal (fork author)

Comment Endpoints

MethodPathAuthDescription
GET/api/commentsNoList comments (query: targetId, targetType, parentCommentId, cursor, limit)
POST/api/commentsYesCreate a comment (supports threading via parentCommentId)
PUT/api/comments/:idYesEdit a comment
DELETE/api/comments/:idYesDelete a comment

Reaction Endpoints

MethodPathAuthDescription
GET/api/reactionsNoGet reaction counts (query: targetId, targetType)
POST/api/reactionsYesToggle a reaction (upvote or flag)

Notification Endpoints

All notification endpoints require authentication.

MethodPathAuthDescription
GET/api/notificationsYesList notifications (query: cursor, limit)
GET/api/notifications/unread-countYesGet unread notification count
POST/api/notifications/mark-readYesMark all notifications as read
POST/api/notifications/:id/mark-readYesMark a single notification as read

SDK Wrappers

The SDK wraps these REST calls so you don't need to manage fetch and auth headers manually:

// sdk.indexer.draft.*
await sdk.indexer.draft.create({ title: "...", description: "...", allocations: [...] });
await sdk.indexer.draft.list({ curator: "0x..." });
await sdk.indexer.draft.update(id, { title: "Updated title" });
await sdk.indexer.draft.publish(id, strategyAddress);

// sdk.indexer.comment.*
await sdk.indexer.comment.create({ targetId: strategyId, targetType: "strategy", body: "Nice picks!" });
await sdk.indexer.comment.list({ targetId: strategyId, targetType: "strategy" });

// sdk.indexer.reaction.*
await sdk.indexer.reaction.toggle({ targetId: commentId, targetType: "comment", type: "upvote" });

// sdk.indexer.notification.*
const { data: notifications } = await sdk.indexer.notification.list();
const count = await sdk.indexer.notification.unreadCount();

Social Layer Hooks

React hooks for the social layer. These wrap the SDK methods above with TanStack Query for caching, refetching, and optimistic updates.

Draft Hooks

HookTypeDescription
useDrafts(params?, opts?)QueryList drafts with cursor pagination
useDraft(id, opts?)QueryGet a single draft by ID
useDraftForks(source, params?, opts?)QueryList forks of a strategy or draft
useVersions(draftId, opts?)QueryList version history for a draft
useCreateDraft()MutationCreate a new draft
useUpdateDraft()MutationUpdate draft title, description, or allocations
useDeleteDraft()MutationSoft-delete a draft
useForkDraft()MutationFork a strategy or draft
usePublishDraft()MutationPublish draft on-chain (creates strategy + marks published)
useProposeMerge()MutationPropose merging a fork into its parent
useAcceptMerge()MutationAccept a merge proposal (parent owner)
useRejectMerge()MutationReject a merge proposal (parent owner)
useWithdrawMerge()MutationWithdraw a merge proposal (fork author)
useUpdateDraftVisibility()MutationChange draft visibility

Comment Hooks

HookTypeDescription
useComments(params, opts?)QueryList comments for a strategy or draft
useCreateComment()MutationPost a comment (supports threading)
useUpdateComment()MutationEdit a comment
useDeleteComment()MutationDelete a comment

Reaction Hooks

HookTypeDescription
useReactions(targetId, targetType, opts?)QueryGet reaction counts and user's reactions
useToggleReaction()MutationToggle an upvote or flag

Notification Hooks

HookTypeDescription
useNotifications(params?, opts?)QueryList notifications (requires auth)
useUnreadNotificationCount(opts?)QueryPoll unread count (auto-refetches every 30s)
useMarkNotificationsRead()MutationMark all or a single notification as read
import {
  useComments,
  useCreateComment,
  useReactions,
  useToggleReaction,
  useNotifications,
  useUnreadNotificationCount,
} from "@curator-studio/sdk/react";

function CommentSection({ strategyId }: { strategyId: string }) {
  const { data: comments } = useComments({
    targetId: strategyId,
    targetType: "strategy",
  });
  const { data: reactions } = useReactions(strategyId, "strategy");
  const { mutate: createComment } = useCreateComment();
  const { mutate: toggleReaction } = useToggleReaction();

  return (
    <div>
      <button
        onClick={() =>
          toggleReaction({
            targetId: strategyId,
            targetType: "strategy",
            type: "upvote",
          })
        }
      >
        Upvote ({reactions?.counts.upvote ?? 0})
      </button>
      {comments?.data.map((c) => (
        <p key={c.id}>{c.body}</p>
      ))}
    </div>
  );
}

Permissionless Entry Points

The contracts are designed so that most operations don't require special access. This is what makes keepers, agents, and bots viable.

FunctionLayerAccessDescription
strategy.distribute(token)ContractAnyoneDistribute strategy balance to recipients
yieldRedirector.harvest()ContractAnyoneSkim yield surplus and distribute
Fund a strategy (transfer tokens)ContractAnyoneSend ERC-20 or ETH to the strategy address
warehouse.withdraw(owner, token)ContractOwner onlyRecipient claims their balance
strategy.rebalance(allocations)ContractOwner onlyUpdate allocation weights
strategy.create(config)ContractAnyone (via factory)Deploy a new strategy
POST /api/draftsRESTAuthenticatedCreate a strategy draft
POST /api/commentsRESTAuthenticatedComment on a strategy or draft
POST /api/reactionsRESTAuthenticatedUpvote or flag content
GET /api/draftsRESTAnyoneList public drafts
GET /api/commentsRESTAnyoneList comments

Tenant Scoping

Each tenant operates under its own ENS domain with isolated factory contracts but shared underlying infrastructure.

LayerTenant-scopedShared
ContractsStrategyFactory, SubnameRegistrarStrategy impl, SplitsWarehouse, ENS infra
IndexertenantId filter on strategiesSame Ponder instance, same endpoint
SDKtenant option resolves factory + ENS domainSame CuratorSDK class
Frontendtenant prop on providerSame codebase serves any tenant

A custom client just sets a different tenant value and sees only that tenant's strategies, while sharing the same warehouse, indexer, and contract infrastructure.

On this page