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/sdkimport { 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 layer — CuratorSDK class. Pure TypeScript, no framework dependency. Works in Node.js, Deno, Bun, browsers, serverless functions.
React layer — CuratorProvider + 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
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/versions | No | List versions (query: draftId required) |
GET | /api/versions/:id | No | Get a single version by ID |
Curator Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/curators/:address/stats | No | Get curator stats (strategies, drafts, forks) |
Draft Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/drafts | No | List drafts (query: curator, sourceStrategy, sourceDraft, proposalsFor, cursor, limit) |
POST | /api/drafts | Yes | Create a draft |
GET | /api/drafts/:id | No | Get a single draft |
PUT | /api/drafts/:id | Yes | Update title, description, or allocations |
DELETE | /api/drafts/:id | Yes | Soft-delete a draft |
PUT | /api/drafts/:id/visibility | Yes | Set visibility (private, unlisted, public) |
POST | /api/drafts/fork | Yes | Fork a strategy or draft (auto-proposes merge) |
POST | /api/drafts/:id/publish | Yes | Publish draft on-chain |
POST | /api/drafts/:id/propose-merge | Yes | Propose merging a fork into its parent |
POST | /api/drafts/:id/accept-merge | Yes | Accept a merge proposal (parent owner) |
POST | /api/drafts/:id/reject-merge | Yes | Reject a merge proposal (parent owner) |
POST | /api/drafts/:id/withdraw-merge | Yes | Withdraw a merge proposal (fork author) |
Comment Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/comments | No | List comments (query: targetId, targetType, parentCommentId, cursor, limit) |
POST | /api/comments | Yes | Create a comment (supports threading via parentCommentId) |
PUT | /api/comments/:id | Yes | Edit a comment |
DELETE | /api/comments/:id | Yes | Delete a comment |
Reaction Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/reactions | No | Get reaction counts (query: targetId, targetType) |
POST | /api/reactions | Yes | Toggle a reaction (upvote or flag) |
Notification Endpoints
All notification endpoints require authentication.
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /api/notifications | Yes | List notifications (query: cursor, limit) |
GET | /api/notifications/unread-count | Yes | Get unread notification count |
POST | /api/notifications/mark-read | Yes | Mark all notifications as read |
POST | /api/notifications/:id/mark-read | Yes | Mark 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
| Hook | Type | Description |
|---|---|---|
useDrafts(params?, opts?) | Query | List drafts with cursor pagination |
useDraft(id, opts?) | Query | Get a single draft by ID |
useDraftForks(source, params?, opts?) | Query | List forks of a strategy or draft |
useVersions(draftId, opts?) | Query | List version history for a draft |
useCreateDraft() | Mutation | Create a new draft |
useUpdateDraft() | Mutation | Update draft title, description, or allocations |
useDeleteDraft() | Mutation | Soft-delete a draft |
useForkDraft() | Mutation | Fork a strategy or draft |
usePublishDraft() | Mutation | Publish draft on-chain (creates strategy + marks published) |
useProposeMerge() | Mutation | Propose merging a fork into its parent |
useAcceptMerge() | Mutation | Accept a merge proposal (parent owner) |
useRejectMerge() | Mutation | Reject a merge proposal (parent owner) |
useWithdrawMerge() | Mutation | Withdraw a merge proposal (fork author) |
useUpdateDraftVisibility() | Mutation | Change draft visibility |
Comment Hooks
| Hook | Type | Description |
|---|---|---|
useComments(params, opts?) | Query | List comments for a strategy or draft |
useCreateComment() | Mutation | Post a comment (supports threading) |
useUpdateComment() | Mutation | Edit a comment |
useDeleteComment() | Mutation | Delete a comment |
Reaction Hooks
| Hook | Type | Description |
|---|---|---|
useReactions(targetId, targetType, opts?) | Query | Get reaction counts and user's reactions |
useToggleReaction() | Mutation | Toggle an upvote or flag |
Notification Hooks
| Hook | Type | Description |
|---|---|---|
useNotifications(params?, opts?) | Query | List notifications (requires auth) |
useUnreadNotificationCount(opts?) | Query | Poll unread count (auto-refetches every 30s) |
useMarkNotificationsRead() | Mutation | Mark 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.
| Function | Layer | Access | Description |
|---|---|---|---|
strategy.distribute(token) | Contract | Anyone | Distribute strategy balance to recipients |
yieldRedirector.harvest() | Contract | Anyone | Skim yield surplus and distribute |
| Fund a strategy (transfer tokens) | Contract | Anyone | Send ERC-20 or ETH to the strategy address |
warehouse.withdraw(owner, token) | Contract | Owner only | Recipient claims their balance |
strategy.rebalance(allocations) | Contract | Owner only | Update allocation weights |
strategy.create(config) | Contract | Anyone (via factory) | Deploy a new strategy |
POST /api/drafts | REST | Authenticated | Create a strategy draft |
POST /api/comments | REST | Authenticated | Comment on a strategy or draft |
POST /api/reactions | REST | Authenticated | Upvote or flag content |
GET /api/drafts | REST | Anyone | List public drafts |
GET /api/comments | REST | Anyone | List comments |
Tenant Scoping
Each tenant operates under its own ENS domain with isolated factory contracts but shared underlying infrastructure.
| Layer | Tenant-scoped | Shared |
|---|---|---|
| Contracts | StrategyFactory, SubnameRegistrar | Strategy impl, SplitsWarehouse, ENS infra |
| Indexer | tenantId filter on strategies | Same Ponder instance, same endpoint |
| SDK | tenant option resolves factory + ENS domain | Same CuratorSDK class |
| Frontend | tenant prop on provider | Same 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.