Docs · hooks, protocol + API v1 · interactive
Conductor docs
Keeper jobs for Uniswap v4 hooks: the pool checker, the event markers and the hook job templates. Then how automations work, the policy engine, the hook fees -> rewards workflow, and every API route: automation, the control room and the data layer underneath. Reads need no key and run right here. All paths are prefixed with /v1.
On this page
Overview
Conductor runs the work your v4 hook can't: keeper jobs for Uniswap v4 hooks on BSC Mainnet (chainId 56), run by operators inside rules the hook's owner sets on chain, with an open indexer underneath. Hooks and automations: see the next sections. Data layer: register an address, then read holders, balances, transfers and decoded events over HTTP, subscribe to signed webhooks, and export holder snapshots at any indexed block.
Base URL: the API host, or this website, which proxies /v1/* to it. Locally the API listens on http://127.0.0.1:8787. JSON in, JSON out, except the CSV snapshot.
export CONDUCTOR="http://127.0.0.1:8787"
curl -s "$CONDUCTOR/v1/health"Every request on this page comes in three forms (curl, the TypeScript SDK, the conductor CLI) generated from one definition, pointed at this site's origin. Pick a form once and every example follows. Read routes have a Try it panel that sends the real request to this site's /v1 and shows the answer.
Conventions
| Topic | Rule |
|---|---|
| Addresses | Accepted in any case. Always returned lowercase. Invalid => 400 invalid_address. |
| uint256 | balance, amount, tokenId, totalSupply, total are decimal strings in raw units. formatted = raw / 10^decimals, no rounding. |
| Numbers | Block numbers and counts are JSON numbers. |
| Times | ISO-8601 UTC strings. blockTime may be null. |
| confirmed | blockNumber <= safeBlock, where safeBlock = headBlock - 12 by default. |
| Lists | { "data": [...], "next": "<cursor>" | null }. Pass next back as ?cursor= with the same other params. Bad cursor => 400 invalid_cursor. |
| limit | Default 100. Max 1000 for holders, events, transfers, balances; 200 for contracts, webhooks, deliveries. Out of range is clamped. |
| order | desc (default, newest first) or asc, for events and transfers. |
| block | Optional as-of block on holder, balance, owner and snapshot reads. Must satisfy startBlock - 1 <= block <= indexedThrough, else 409 not_indexed. |
| Kinds | erc20, erc721 (holders = token counts), abi (events only; holder endpoints => 409 conflict). |
Errors
{ "error": { "code": "not_found", "message": "contract 0xabc... is not registered" } }| HTTP | code | When |
|---|---|---|
| 400 | bad_request, invalid_address, invalid_cursor | Validation. |
| 401 | unauthorized | Write route, keys configured, x-api-key missing or unknown. Alert routes: no valid signature or session (details.reason). |
| 403 | forbidden | Alert routes: the signature or session is valid but not from the owner. |
| 404 | not_found | Unknown contract, webhook or route. |
| 409 | conflict | Endpoint not valid for the contract kind; snapshot larger than the row limit. |
| 409 | not_indexed | block outside the indexed range. Details carry indexedThrough and startBlock. |
| 422 | unsupported | Address has no code; kind not detectable and no ABI; ABI invalid. |
| 422 | limit_reached | Contract or webhook limit reached. |
| 429 | rate_limited | Per-IP limit. x-ratelimit-* and retry-after headers are set. |
| 500 | internal | Bug. |
| 503 | unavailable | Database unreachable. |
Auth and limits
- Reads need no auth.
- Writes (
POST /v1/contractsand every/v1/webhooksroute) takex-api-key: <key>when the operator configures keys. With no keys configured, writes are open. POST /v1/contractsalso works without a key when public registration is on (the default), limited to 5 per minute per IP. The register form on/datauses this.- Webhooks are scoped to the key that created them: list, get, patch and delete only see your own.
- Alert webhooks about automations are authorised by the owner wallet instead: a signature per request or a short session. See Alerts.
- Reads: 300 requests per minute per IP. Requests with a valid key are exempt.
Keeper jobs for v4 hooks
Conductor runs the work your v4 hook can't. A hook only wakes up inside a swap or a liquidity call. It cannot read the clock, rebalance, refresh an oracle, step a dynamic fee, claim its own fees or pay anyone out on a schedule. Conductor's operators do that, inside rules the hook's owner sets on chain, with every run recorded.
A keeper job is an ordinary automation task: a trigger, a list of calls, and the policy that bounds them. What makes it hook-aware is the trigger: it can read the pool's state through the UniswapV4PoolChecker and wake on the pool's events through an event marker.
- The hook's owner (or the project behind it) owns the
Automationcontract and its policy. - Operators watch the pool, simulate the job, and submit
execute; the contract rejects any call the policy does not allow. - Every run is recorded with its trigger, transactions and balance changes, like any other run.
The v4 pool checker
UniswapV4PoolChecker reads getSlot0(poolId) and getLiquidity(poolId) from the v4 StateView. A task sets it as trigger.checker with checkerData = abi.encode(PoolCondition), where PoolCondition is { mode, poolId, tickLower, tickUpper, liquidityThreshold }. When it is ready its payload is abi.encode(tick, sqrtPriceX96, liquidity, lpFee).
| Mode | Ready when |
|---|---|
Any | Always. Carries the poolId so the worker can wake the task on that pool's events. |
PriceOutsideRange | tick < tickLower or tick >= tickUpper. |
PriceInsideRange | tickLower <= tick < tickUpper. |
LiquidityBelow | liquidity < liquidityThreshold. |
LiquidityAbove | liquidity > liquidityThreshold. |
- A pool that was never initialised (
sqrtPriceX96 == 0) is not ready in any mode exceptAny. - The range modes revert
InvalidRange()whentickLower >= tickUpper. - Task eligibility for these tasks carries
{ tick, sqrtPriceX96, liquidity, lpFee, poolId, mode, inRange }.
Event markers
Markers are off-chain wake-ups: trigger.eventMarker is the keccak256 of the marker string, and the worker re-checks the task as soon as a matching PoolManager event is indexed instead of waiting for the next interval. The task's pool is the poolId in its PoolCondition; a v4 marker on a task with another checker is ignored.
| Marker | Wakes on |
|---|---|
v4.pool.swap | Swap on the PoolManager for the task's pool. |
v4.pool.liquidity | ModifyLiquidity for the task's pool. |
v4.pool.initialize | Initialize whose hooks is the task's hook. The pool is not known in advance: the condition's poolId is zero and the hook address is read from balanceHolder. |
v4.pool.any | Any of the above for the task's pool. |
Hook job templates
The build console at /app/new and GET /v1/workflows list these. Planned templates describe the shape they will ship with; until then, start from a blank automation and add the task by hand.
| Id | Name | Status | What it does | Trigger |
|---|---|---|---|---|
pons-rewards/v1 | Claim hook fees -> fund rewards | live | Sweep the curve, sweep the hook's pool fees, claim the escrow, swap on v4, send to rewards. | PonsFeeChecker, pons.escrow.credited |
v4-rebalance/v1 | Rebalance when price leaves the range | planned | Collect, burn the position, mint around the current tick. | PriceOutsideRange, v4.pool.swap |
v4-oracle-refresh/v1 | Refresh a hook's oracle | planned | Call hook.update() (owner-chosen selector). | interval + v4.pool.swap |
v4-dynamic-fee/v1 | Step a dynamic fee with volume | planned | hook.setFee(poolId, fee) from a volume band. | interval, v4.pool.swap |
v4-twamm/v1 | Run a large order in slices | planned | Universal Router swap of a fixed slice. | interval + PriceInsideRange |
v4-liquidity-floor/v1 | Top up a pool below a liquidity floor | planned | Mint liquidity from the automation's balance. | LiquidityBelow, v4.pool.liquidity |
blank | Blank automation | live | Create with a deposit. | none |
Hooks and pools API
Read-only, no key. The indexer mirrors the PoolManager's Initialize, Swap and ModifyLiquidity events into hooks and pools. A hook's flags are the low 14 bits of its address; permissions spells them out (beforeInitialize is bit 13, afterRemoveLiquidityReturnDelta bit 0).
| Route | Returns |
|---|---|
GET /v1/hooks?limit&cursor | Hooks, most pools first: address, flags, permissions, pools, swaps in 24 h, first seen, known name. |
GET /v1/hooks/:address | One hook and its pools, newest first. |
GET /v1/pools?hook=&limit&cursor | Pools: currencies, fee (dynamic or fixed), tick spacing, hook, tick, liquidity, swap count. |
GET /v1/pools/:poolId | One pool with its last 20 swaps and live tick, sqrtPriceX96, liquidity, lpFee from StateView (null when the RPC fails). |
Hook Studio
Hook Studio builds a Uniswap v4 hook on /studio. Describe it on /app/new is different: it sets up automations around a hook that already exists. Here you build the hook itself, from tested blocks, and deploy it with its pool from your own wallet.
- No free-form code. Every Studio hook is the same contract,
ComposableHook, with an immutable config of blocks chosen at deploy. Building means choosing blocks and their parameters; every hook gets the same checks. - No admin key, no upgrade, no owner withdraw of swapper funds. The config can't change after deploy.
- Unaudited starting point: keep pools small until the contract is reviewed. The page and every hook page say so.
- The builder's wallet deploys. Conductor never deploys a hook with its own keys.
| Block | Inputs (limits) | What it does |
|---|---|---|
dynamicFee | base fee 1 to 100 bps, highest fee up to 300 bps, sensitivity 1 to 100 bps per 100 ticks (about 1% of price), window 60 s to 24 h | the LP fee rises with recent price movement and eases back to the base |
hookFee | creator, builder (up to 50 bps, paid to the deploying wallet) and treasury shares, 1 to 300 bps in total | a fee in the input currency on every swap, kept in the hook per recipient; distribute() pays only the recipients set at deploy |
maxSwap | up to 10% of the input reserves, or a set amount | refuses any swap above the cap |
launchGuard | 1 second to 7 days, a cap of the same shape | an extra cap while the pool is young; when both caps apply, the lower one wins |
The simulator runs a scenario (a preset or your own list of swaps) through the same math as the contract and returns each swap: the LP fee, the hook fee, whether it was refused, and what each recipient has accrued. Every number on the page comes from it or from the chain.
Body { text, builder?, answers?, currency0?, currency1? }. Returns { status, understood, spec, rows, questions, reasonCode, explanation }. status is ready (the HookSpec fills the block form, so every number stays editable), needs_input (questions with quick answers; answer by sending answers) or refused. 503 compose_disabled when the API has no model: pick the blocks instead, they build the same hook.
Body { spec, pool?, scenario }, where scenario is { preset, count?, seed? } (random, whale, volatility, launch) or { swaps: [{ zeroForOne, amountIn, at? }] } with at in seconds after pool init. Returns { steps, totals, summary, model, note }: each step's LP fee, hook fee per recipient and whether it was refused, and the totals per currency.
Body { spec, builder, pool: { currency0, currency1, tickSpacing, sqrtPriceX96 or price, fee? } }. Returns { hook, salt, poolId, pool, pairReordered, tx, check, summary, automation, warnings }: the hook address (a CREATE2 salt mined so the address carries exactly the hook's permission flags), the eth_call check and the one transaction the builder signs to deploy the hook and initialize the pool. 422 check_failed when the call would revert, 409 when that hook is already deployed, 503 not_configured before the factory is deployed. Spec errors answer 400 with details.issues[{ path, reason, message }].
GET/v1/hook-studio/hooks/:address
Every hook deployed through the factory, from its HookDeployed event. GET /v1/hooks/:address carries the same record as studio, and the hook page shows Built with Hook Studio with its config.
- Fees wait in the hook until someone calls
distribute(). After deploy, attach the distribute automation on/app/new: it calls it on a schedule, and it can only pay the recipients set in the hook. - With a hook fee, max swap or launch guard on, swaps must specify the amount in: the hook refuses exact-output swaps (
ExactOutputNotSupported). A dynamic-fee-only hook accepts both. - Permissions: every Studio hook has before initialize and before swap; a hook fee adds before swap returns delta (flags
0x2080or0x2088). Prepare mines the salt that puts exactly these bits in the address. - The builder share is the per-swap fee for the person who built the hook. A public marketplace for launching on someone else's hook comes later.
Recipes
A recipe is a public, versioned automation plan with typed inputs. Conductor agents derive recipes from the hooks and pools index, simulate them on current chain state and publish the ones that pass every guardrail. Anyone can fork a published recipe into an automation they own. Agents never sign and hold no keys.
| Rule | Candidate | Applies when | Auto-publish |
|---|---|---|---|
pons-fees-rewards | launch token | a pons meme hook pool with 20 or more swaps | yes |
swap-count-payout | pool | the hook has afterSwap and 10 or more swaps in 24 h | yes |
price-range-action | pool | the pool has liquidity and a known tick | yes |
dynamic-fee-step | pool | the pool uses a dynamic fee | never: waits for review |
- Two engines:
initsubstitutes input words into anAutomationInitJson;pons-rewardsmerges inputs into the fees -> rewards params. - Each task's
templateIdiskeccak256(templateName):recipe/<slug>forinit,pons-rewards/v1forpons-rewards. - "Built by Conductor agents" means the recipe's author is an agent. "Run by Conductor" means a Conductor address owns the automation. Forks are listed by address only.
- Inputs have a type (
address,token,uint,seconds,tick,bytes4), optional bounds and a default;$owneris the forker's wallet.
GET/v1/recipes
Page<RecipeSummary>, most forked first. Filters: status (draft, published, retired; default published), hook (an address the recipe fits), permission (a hook permission such as afterSwap), rule, author (agent or human), limit up to 100, cursor.
RecipeDetail: the published version (else the current one; ?version=n pins one) with its template, inputs, policy in plain words, simulation report (block, deltas, gas, fee, guardrails G1 to G8), the reference automation's live runs, every version and recent agent activity. Unknown slug or version: 404.
Page<RecipeFork>: automations forked from the recipe (confirmed or diverged), by address.
Fork a recipe
POST/v1/recipes/:slug/materialize
Body { version?, owner, inputs }. Compute only, nothing is stored. Returns init (engine init) or ponsParams (engine pons-rewards). Missing, mistyped or out-of-bounds inputs come back as problems: [{ path, message }] with a 200.
- Then sign as usual:
POST /v1/automations/prepare-create(or the pons-rewards preview) withrecipe: { slug, version, inputs }records a pending fork at the predicted address. - The version must be published (
409) and a task must carry its template id (400). Pending forks expire after 24 h. - Once created, the automation shows "Forked from <name> v<n>" (
forkedFromon/v1/automations*) and the recipe's fork count goes up. - The console does all of this from "Fork this recipe":
/app/new?recipe=<slug>, or/app/new/pons-rewards?recipe=<slug>for the pons engine.
import { Conductor, recipeForkPath } from "@conductor/sdk";
const conductor = new Conductor({ baseUrl: process.env.CONDUCTOR_API_URL });
const recipe = await conductor.getRecipe("pons-fees-rewards-gitfund-a24326");
const plan = await conductor.materializeRecipe(recipe.slug, {
owner: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8",
inputs: { rewardsTarget: "0x70997970c51812dc3a010c7d01b50e0d17dc79c8" },
});
if (plan.problems.length) throw new Error(plan.problems.map((p) => `${p.path}: ${p.message}`).join("; "));
console.log(recipeForkPath(recipe.slug, plan.engine), plan.ponsParams ?? plan.init);conductor recipes list --permission afterSwap
conductor recipes get pons-fees-rewards-gitfund-a24326
conductor recipes fork pons-fees-rewards-gitfund-a24326 --owner 0x70997970c51812dc3a010c7d01b50e0d17dc79c8 --input rewardsTarget=0x70997970c51812dc3a010c7d01b50e0d17dc79c8 --out params.json
conductor recipes forks pons-fees-rewards-gitfund-a24326Agents feed
| Agent | Runs | Does |
|---|---|---|
conductor.scan | every 10 min | applies the rules; a new candidate becomes a draft recipe, a changed one a new version |
conductor.simulate | every 2 min | simulates unsimulated versions at latest with each input's simulation value; checks guardrails |
conductor.publish | after each simulation | publishes when the rule allows it, the simulation is ok and every guardrail passes; else holds it for review |
conductor.refresh | daily | re-simulates published versions; three failures in a row flag the recipe |
Page<AgentRun>, newest first. Filters: agent, recipe (a slug), action (scanned, derived, updated, simulated, published, held, flagged, retired, referenced), limit up to 100. actor is an agent or a reviewer's address; jobId links to the job.
AgentJob (job_ + 24 hex): agent, kind, status, inputs, outputs, log lines and the block it ran at. Jobs are kept 30 days.
conductor agents runs --agent conductor.publish --limit 10
conductor agents runs --recipe pons-fees-rewards-gitfund-a24326
conductor agents job job_0123456789abcdef01234567Review
Recipes held by the publish agent wait for a reviewer. Publishing and retiring take an API key or an owner-auth signature from an address in RECIPE_REVIEWERS (default: the Conductor owners). Both write a human row to the agents feed.
- Body
{ version?, note, simulationWaiver? }. Publishing needs a passing simulation unlesssimulationWaiverexplains why; the waiver is shown on the recipe page. - The signed message is the alerts one with
action: publish-recipeorretire-recipe,scope: owner,address= the signer andurl=keccak256of<slug>@<version>. The SDK fills in the version it signs. - A valid signature from a non-reviewer is
403. A retired recipe keeps its page and its forks but cannot be forked again.
Conductor alerts
domain: useconductor.xyz
action: publish-recipe
scope: owner
address: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
webhook: -
url: 0x<keccak256 of pons-fees-rewards-gitfund-a24326@1>
events: -
chainId: 56
nonce: 0123456789abcdef0123456789abcdef
issuedAt: 1792800000export CONDUCTOR_OWNER_PRIVATE_KEY=0x... # or --owner-key-file reviewer.key
conductor recipes publish pons-fees-rewards-gitfund-a24326 --version 1 --note "simulation reviewed"
conductor recipes publish pons-fees-rewards-gitfund-a24326 --note "selector checked" --waive-simulation "fee hook reverts in simulation only"
conductor recipes retire pons-fees-rewards-gitfund-a24326 --note "superseded by v2"Describe it
Type what an automation should do in one sentence on /app/new. Conductor's model drafts a proposal in a closed schema, never calldata. Code then compiles it into the same policy and task a workflow builds, checks every number against the guardrails and simulates it on current chain state. The plan you read is rendered from the compiled config, and your wallet signs it with the normal create flow. Drafting needs no wallet.
- Each plan row says where its value came from:
from your text(the matching words are underlined in your sentence),you answered,looked up(your launches, automations and tokens) orConductor default. - Each limit says who holds it:
Enforced on chainmeans the contract rejects anything over it, whoever asks.Operator rulemeans Conductor's operators follow it; the chain caps still hold if they don't. - The operator fee cap is its own number. It is not counted inside the "never spend more than" limit you wrote.
- Rule changes wait 1 day by default, so a loosening is public a day ahead. The owner can switch that off in the plan.
- Plain-English plans are capped at 1 ETH a day (a warning above 0.1 ETH). The rule builder still allows more.
| Status | Means |
|---|---|
ready | compiled, every guardrail passed, simulated (or skipped with a warning): you can sign |
needs_input | a question comes back with quick answers; answering is a refinement |
blocked | compiled, but a guardrail or the simulation failed; the plan says which |
unsupported | nothing in the sentence can be built yet; the answer names the closest thing that works |
refused | the model declined the request |
POST/v1/compose
Body { text, owner?, automation?, answers?, policyDelayOptOut? }. Returns a ComposeProposal: draftId (cmp_...), status, plan rows with provenance and enforcement, questions, unsupported, simulation, guardrails and warnings. With accept: text/event-stream it streams stage events (context, proposal, compiled, simulated) before the proposal.
POST/v1/compose/:draftId/refine
Body { answers } or { edit }: the sentence plus the new turn is drafted again. Up to 8 turns per draft.
The latest proposal and its turns. Drafts expire after 24 hours unless signed.
- Sign with
POST /v1/automations/prepare-createand{ owner, draftId }: the API re-compiles and re-checks the draft itself. The browser never sends a trusted config. - Limits: 5 drafts a minute and 30 a day per IP, 60 a day per verified owner. When drafting is offline (
503 compose_disabled,model_unavailableordaily_cap) the workflows and the rule builder build the same automations. - The automation page shows the sentence it was described from, kept by Conductor and not stored on chain. The owner can hide it.
How automation works
Tell your contract what to do, and when. Each project owns an Automation contract: the policy (which calls it may make, which assets may move, to whom, how much per epoch) and its tasks. Conductor's operators watch the triggers, simulate, and submit execute; the contract rejects anything outside the rules. Every run is recorded.
| Role | Who | Can |
|---|---|---|
| Owner | your wallet (an EOA or a Safe) | everything on its automation: tasks, policy, pause, revoke operators, withdraw any asset, ownerCall |
| Operator | a keeper key in the OperatorRegistry (v0: Conductor's worker) | execute tasks within the rules, abortRun after a timeout, get paid a capped fee |
| Protocol owner | deployer key, then a multisig | add operators and set the implementation for NEW automations; never moves your funds |
| Anyone | simulate, every view, deposit ETH |
- Writes never come from Conductor: the API returns transactions (
TxRequest) and your wallet signs them. - Pause, revoking an operator, deactivating a task, withdrawing and tightening the policy are always immediate.
- The owner can always exit:
withdrawandownerCallignore pause, policy and timelock.
Policy and limits
A call made by execute is allowed only if its (target, selector) is permitted, it sends ETH only where allowValue is set, its validator (if any) accepts it, and any token transfer/approve goes to an allowed recipient. The Universal Router is only reachable through the UniversalRouterV4Validator, which accepts one exact-in v4 swap whose output returns to the automation.
Every tracked asset (up to 16, ETH first) is measured before and after each call. Gross outflow is charged against a per-asset cap per epoch. Epochs are fixed windows aligned to unix time: a 7-day epoch resets every Thursday 00:00 UTC. Around a boundary up to 2x the cap can move in a short time (end of one epoch, start of the next). Cap 0 = inflow only; uint256 max = uncapped but tracked.
| Change | Applies |
|---|---|
| Lower a cap, remove a target or recipient, restrict operators, raise the timelock | immediately |
| New target, higher cap, new recipient, looser fee policy, shorter timelock | immediately when policyDelay is 0; otherwise queued, executable after the delay |
The timelock is optional and defaults to 0. It cannot protect the owner from themselves; it is a public promise to holders that the rules cannot be loosened without notice (max 30 days).
Operator fees
Paid in ETH from the automation to the operator at the end of an execute in which at least one step succeeded: gasUsed x min(gasprice, maxGasPrice) x (1 + premium) + L1 fee + flat fee, then capped by the per-execute cap, the task's per-run cap, the per-epoch fee budget and the automation's balance. The fee never reverts a run.
| Default | Value |
|---|---|
| maxGasPrice | 1 gwei (56 runs near 0.02 gwei) |
| premium | 20 % |
| max per execute | 0.002 ETH |
| budget per 7-day fee epoch | 0.02 ETH |
| L1 fee | included (ArbGasInfo) |
Keep some ETH in the automation: when its balance is below one run's max fee, or the fee budget is used up, the dashboard asks for a deposit (a plain ETH send to the automation).
Tasks, triggers and runs
A task is up to 16 steps; each step is one call template plus up to 8 slots. Every byte outside a slot is fixed by the owner. Slots are filled at run time: by the contract (a balance, the run's inflow, now + N, a copy of another slot) or, only for OPERATOR slots, by the operator within the owner's bounds (e.g. a swap's minimum output).
| Trigger | Holds when |
|---|---|
| interval | at least N seconds since the last run started |
| balance threshold | an asset balance of the automation (or another holder) is at least X |
| checker | a stateless checker returns true, e.g. PonsFeeChecker: claimable creator fees >= X |
| none | manual: any authorized operator may run it (limits still apply) |
Atomic tasks run in one transaction: a failing required step reverts everything and nothing is charged. allowFailure steps (sweeps, claims) may fail without stopping the run. Run states: pending -> simulating -> submitted -> succeeded | failed; submitted -> stuck (no receipt after gas bumps; the dashboard says recovering) -> resubmitted or failed; skipped when not eligible.
The policy engine
Every run goes through one function, Automation.execute(taskId, resolvedCalls). The operator proposes the calls; the contract binds them to the owner's template, checks each final call against the policy, measures what moved, charges the allowances and pays a capped fee. Anything outside the rules reverts the whole transaction.
allowFailure steps may fail on their own.| Stage | Rule | Revert |
|---|---|---|
| Authorize | !paused && registry.isActiveOperator(op) && !revoked[op] && (!operatorRestricted || allowed[op]); the task exists and is active. | NotAuthorizedOperator, TaskNotFound, TaskInactive |
| Trigger | At run start only (nextStep == 0): every non-zero condition holds (interval, balance threshold, checker). All-zero = manual. | TriggerNotMet(reason) |
| Bind | Same target and calldata length as the template; every byte outside a slot window equals the owner's; without a VALUE slot the value is the template's. Non-OPERATOR slots are computed by the contract and written over the operator's word. | TemplateMismatch(step), OperatorSlotOutOfRange, SlotValueOverflow |
| Check | (target, selector) permitted; value only where allowValue; the validator (if any) accepts the final call; never the automation itself, the factory or the registry. | CallNotPermitted, ValueNotPermitted, ValidatorRejected, ForbiddenTarget |
| Call | target.call{value: v}(data) with all gas minus a 50k reserve. An allowFailure step may fail; a required step reverts an atomic run. | StepFailed(step, reason) |
| Deltas | Every tracked asset (max 16, ETH first) is read before and after every call; gross inflow and outflow are summed per asset. | |
| Caps | Outflow is charged against the per-epoch cap and the task's per-run cap. ERC-20 transfer / approve / transferFrom go only to allowed recipients; approvals are reset to 0 before execute returns. | AllowanceExceeded, RunCapExceeded, RecipientNotAllowed, UntrackedAsset |
| Fee | min(computed, maxFeePerExecute, task cap, epoch fee budget, balance) in ETH to the operator, only when a step succeeded. Capped fees emit FeeCapped. | never |
What an operator can choose: when to run (within the trigger), OPERATOR slot values within the owner's bounds (e.g. a swap's minOut), skipping allowFailure steps, and stopping a stepped run. What it cannot: change a target, selector, recipient or any template byte, add calls, or move more than the allowances.
Loosening and tightening
Every policy change is a PolicyUpdate{op, data}. Tightening applies at once. Loosening applies at once when policyDelay is 0; otherwise updatePolicy reverts LooseningRequiresTimelock(index) and the owner queues it: queuePolicyUpdate, then executeQueuedPolicyUpdate at or after the eta (max delay 30 days). Pause, revoke, deactivate and withdraw are never delayed.
| Op | Tightening (immediate) | Loosening (timelocked when policyDelay > 0) |
|---|---|---|
SET_TARGET | adds a validator where none existed, allowValue not increased | new permission, allowValue false -> true, validator changed or removed |
REMOVE_TARGET | always | |
SET_ASSET | lower cap, same epochLength | new asset, higher cap, any epochLength change |
REMOVE_ASSET | always | |
SET_RECIPIENT | allowed = false, or allowed = true for an address already allowed (no-op) | allowed = true for an address not yet allowed |
SET_FEE_POLICY | every limit equal or lower, feeEpochLength equal or longer, includeL1Fee not enabled | any limit higher, epoch shorter, includeL1Fee false -> true |
SET_OPERATOR_RESTRICTION | false -> true | true -> false |
SET_OPERATOR_ALLOWED | allowed = false, or allowed = true for an operator already allowed (no-op) | allowed = true for an operator not yet allowed |
SET_POLICY_DELAY | increase | decrease |
Deployed contracts
BSC Mainnet, chainId 56. The factory row is checked against what this site's API reports in GET /v1/automation-stats. Per-launch pons curve and token addresses are never listed: they are read from ponsV2Factory.getLaunchedToken(token).
Asking the API which factory it serves.
Verification pending
Source verification on the explorer is pending for Conductor's contracts: the addresses below are the deployment record, the bytecode is on chain, and the source is contracts/src at commit 71ac4e2.
| Contract | Address · Etherscan | Role |
|---|---|---|
| AutomationFactorychecking | 0xf5ca3e665269ab3fa5da098c093c58fabf5a746f | creates automations (EIP-1167 clones) and indexes them by owner |
| Automation (implementation) | 0x754afeb54c37756db86e110df90289ce12a97996 | the code every automation clone runs; initializers disabled |
| OperatorRegistry | 0x5f19edd6447af259bdffe8c847ec4e9f516be085 | allowlist of operators with an active flag |
| IntervalChecker | 0x6275074a33492d8da99d9e6a9b73a0c5ad1bb36b | stateless: at least N seconds since the last run |
| BalanceThresholdChecker | 0x93cc92e8aa4d26dd0b9c12d9a740cd56a4aec9da | stateless: a balance is at least X |
| PonsFeeChecker | 0x4fa61399164b06b5ca4499d6eac3b9b237782905 | stateless: claimable pons creator fees are at least X |
| UniversalRouterV4Validator | 0x8fecea6e128ab91d89102ecad7b40324398335b8 | the only accepted Universal Router call shape |
| PonsRewardsFlowBuilder | 0x5228b68010acb6d3f926e44d3ee2fbac64debb8c | canonical encoder of the fees -> rewards task (view) |
Called by the fees -> rewards template (verified addresses)
| Contract | Address · Etherscan | Role |
|---|---|---|
| pons v2 factory | 0x7ed598bcef8bd9edd8c97a195c6d13f40801ec7e | launch records; transferCreatorFeeRecipient |
| pons v2 meme hook | 0xe5e702641ea86f4ae6cc3cdaed2b886f976be044 | pool hook; sweepPoolFees |
| pons v2 fee escrow | 0xd3afeb2a57f70ef218aa82451c51b2fb0416ac9e | creator fee escrow; claim() |
| Uniswap v4 Universal Router | 0x8876789976decbfcbbbe364623c63652db8c0904 | the swap step (V4_SWAP, exact in) |
| Uniswap v4 PoolManager | 0x8366a39cc670b4001a1121b8f6a443a643e40951 | pools of graduated pons launches |
| Uniswap v4 V4Quoter | 0x987e643e0d2b5a2b56ab52a0c0036c508d3451fa | quotes for the operator's minOut |
The workflow
Template pons-rewards/v1: whenever your pons v2 project has earned enough creator fees, claim them, swap the ETH to the reward token on its graduated Uniswap v4 pool, and fund your rewards contract. The automation itself becomes the creator fee recipient.
| # | Call | On failure | Filled at run time |
|---|---|---|---|
| 0 | curve.sweepFees(0) | continue | — |
| 1 | memeHook.sweepPoolFees(poolId, 0, 0) | continue | — |
| 2 | feeEscrow.claim() | continue | — |
| 3 | universalRouter.execute{value}(V4_SWAP, ...) | revert all | value = ETH balance - reserve; minOut by the operator (quote - slippage); deadline = now + 300 |
| 4 | rewardToken.transfer(target, balance) or approve + fund(uint256) | revert all | amount = reward token balance |
| Field | Meaning | Default |
|---|---|---|
| feeToken | the pons v2 launch whose creator fees are claimed (ETH-quoted) | required |
| rewardToken | token bought with the fees: a graduated (phase 2) ETH-quoted launch; may be the same token | required |
| rewardsTarget | rewards contract, distributor or wallet that receives the tokens | required |
| fundMode | transfer, or approveAndCall with fundSelector (one uint256 argument) | transfer |
| minClaimable | claimable fees that make a run worthwhile (wei) | required |
| minInterval | seconds between runs | 3600 |
| nativeReserve | ETH kept for operator fees | 0.001 ETH |
| maxSwapInPerRun | per-run ETH cap, 0 = none | 0 |
| nativeCapPerEpoch | ETH outflow allowance per epoch | required |
| rewardCapPerEpoch | reward-token outflow allowance | uncapped |
| epochLength | fixed window in seconds | 604800 (7 days) |
| maxFeePerRun | max operator fee per run (wei) | 0.002 ETH |
Activation
The rule builder previews the plan as you type, then simulates it against the chain with nothing deployed. Activation is blocked until the simulation passes. Then you sign, in order:
- Create the automation with the policy and the task (
factory.createAutomation), or for an existing automationupdatePolicythencreateTask. - Claim what is already credited to you (
escrow.claim()): credited fees do not move with the recipient. - Hand the fees to the automation:
ponsFactory.transferCreatorFeeRecipient(feeToken, automation), signed by the current fee recipient.
Undo at any time: the owner calls ownerCall with transferCreatorFeeRecipient(feeToken, ownerWallet) and withdraws.
| Failure | What happens |
|---|---|
| not enough claimable | eligibility says so; the worker re-checks on the escrow's Credited event |
| sweep needs pons's operator | step 0/1 fails, the run continues |
| price moved | the swap reverts on minOut; the run fails with slippage and retries with a fresh quote |
| epoch allowance used up | AllowanceExceeded in simulation; next check at the epoch end |
| paused / operator revoked | nothing runs until the owner changes it |
Reads
Public, no key. Lists are { data, next } with cursor pagination like every other route. Without deployed contracts every route below except /v1/automation-stats and /v1/workflows answers 503 not_configured. A freshly created automation can be 404 for a few seconds while the mirror catches up. Try it fills automation and run ids from the newest ones the API knows.
curl -s "http://127.0.0.1:8787/v1/automation-stats"GET/v1/automations?owner=&template=&paused=&limit=&cursor=
curl -s "http://127.0.0.1:8787/v1/automations?owner=0x3d8a0670d278c125966f884dc66bea34944a4220&limit=10"curl -s "http://127.0.0.1:8787/v1/automations/0x5b1f000000000000000000000000000000000000"GET/v1/automations/:address/tasks[/:taskId]
curl -s "http://127.0.0.1:8787/v1/automations/0x5b1f000000000000000000000000000000000000/tasks"curl -s "http://127.0.0.1:8787/v1/automations/0x5b1f000000000000000000000000000000000000/tasks/0"GET/v1/automations/:address/runs?taskId=&status=&limit=&cursor=
curl -s "http://127.0.0.1:8787/v1/automations/0x5b1f000000000000000000000000000000000000/runs?limit=20"GET/v1/runs?automation=&status=&limit=&cursor=
curl -s "http://127.0.0.1:8787/v1/runs?status=failed,stuck&limit=20"GET/v1/runs/:id
curl -s "http://127.0.0.1:8787/v1/runs/run_381"curl -s "http://127.0.0.1:8787/v1/operators"curl -s "http://127.0.0.1:8787/v1/workflows"Simulate and preview
POST/v1/simulate
Exactly one of task (an existing task, automation.simulate at latest) or draft (nothing deployed: the AutomationSimulator creation-code call). Always 200 with ok: false and the decoded revert when the execution would fail. Limited to SIMULATE_RATE_PER_MIN per IP.
curl -s -X POST "http://127.0.0.1:8787/v1/simulate" \
-H "content-type: application/json" \
-d '{"task":{"automation":"0x5b1f000000000000000000000000000000000000","taskId":0}}'Try it fills in the newest automation the API knows, so the task exists.
Every simulation is cached for 10 minutes under its id; worker-recorded simulations carry source: "worker" and a runId. 404 after expiry.
curl -s "http://127.0.0.1:8787/v1/simulations/sim_9c2b5e0d4a1f3e7b6c8d9a0b"POST/v1/workflows/pons-rewards/preview
The rule builder's single call: resolves both launches, quotes the swap, builds the task and policy, simulates, and returns the ordered txs to sign plus warnings.
curl -s -X POST "http://127.0.0.1:8787/v1/workflows/pons-rewards/preview" \
-H "content-type: application/json" \
-d '{"owner":"0x3d8a0670d278c125966f884dc66bea34944a4220","feeToken":"0xa24326d9bcbd110c918dcf386472441b9347cbf6","rewardToken":"0x68ff81dfff6eccefb74706fe1e0a86320cb2eb96","rewardsTarget":"0x3d8a0670d278c125966f884dc66bea34944a4220","fundMode":"transfer","minClaimable":"10000000000000000","nativeCapPerEpoch":"1000000000000000000"}'The preview accepts an optional slippageBps (default WORKER_SLIPPAGE_BPS). It answers 400 with details[{ path, message }] for missing or invalid fields and 422 unsupported for a fee or reward token that is not ETH-quoted or not graduated. Draft simulations run on a throwaway clone, so simulation.trigger.ok is usually false: judge the run by executed and stepResults.
Transactions to sign
These routes only encode. They return a TxRequest { chainId, to, data, value, from, description }; send it from the from wallet unchanged.
POST/v1/automations/prepare-create
POST/v1/automations/:address/calldata
| action | args | signer |
|---|---|---|
setPaused | { paused } | owner |
setTaskActive | { taskId, active } | owner |
setOperatorRevoked | { operator, revoked } | owner |
withdraw | { asset, to, amount } (amount may be "max") | owner |
deposit | { amount } (plain ETH send) | anyone |
createTask | { task } | owner |
updatePolicy / queuePolicyUpdate | { updates } | owner |
executeQueuedPolicyUpdate / cancelQueuedPolicyUpdate | { id } | owner |
abortRun | { taskId } | owner |
ownerCall | { calls, value? } | owner |
transferOwnership / acceptOwnership | { newOwner } / {} | owner / pending owner |
curl -s -X POST "http://127.0.0.1:8787/v1/automations/0x5b1f000000000000000000000000000000000000/calldata" \
-H "content-type: application/json" \
-d '{"action":"setPaused","args":{"paused":true}}'POST /v1/policy/encode ABI-encodes policy updates; with automation it also says which updates loosen the policy and whether they apply immediately.
curl -s -X POST "http://127.0.0.1:8787/v1/policy/encode" \
-H "content-type: application/json" \
-d '{"updates":[{"op":"setRecipient","recipient":"0x3d8a0670d278c125966f884dc66bea34944a4220","allowed":true}]}'Terminal
The Conductor CLI's grammar over this site's API: the same console as /app/terminal and the drawer on every /app page (Ctrl+). Reads run here; writes print a transaction for your wallet. Tab completes commands and automation addresses, the arrow keys walk the history, --json prints raw JSON, tail` streams runs and logs.
Dashboard routes
Read-only routes behind /app, /explore and the terminal: an overview, an eligibility timeline, metric series, a leaderboard, the worker's logs and a live stream. Same error envelope, cursor and rate-limit rules as above. overview, timeline, timeseries and leaderboard answer 503 not_configured while AUTOMATION_FACTORY is unset; worker/logs and stream always work. owner (optional wherever accepted) scopes to one owner's automations; omitted = protocol-wide.
Pages on this site
| Route | What | Reads |
|---|---|---|
/app | the control room: tiles, alerts, live run feed, automation cards with allowance bars and next eligible, eligibility timeline, operator health; scoped to the connected wallet or protocol-wide | overview, timeline, runs, stream |
/app/terminal | the terminal full page; a slide-up drawer on every /app page, toggled with Ctrl+` | every read route; writes return calldata for the wallet |
/explore | charts (runs per day, ETH moved, success rate, operators, gas), the leaderboard and recent runs across the protocol | timeseries, leaderboard, runs, stream |
/app/a/:address | one automation: policy, allowances, balances, tasks, runs | automations/:address, runs |
/app/a/:address/runs/:id | one run: steps, transactions, deltas, quote vs realized | runs/:id |
/app/new | create an automation: the fees -> rewards builder or an empty one | workflows, preview, prepare-create |
Overview
curl -s "http://127.0.0.1:8787/v1/dashboard/overview?owner=0x3d8a0670d278c125966f884dc66bea34944a4220"{ "asOf": "2026-10-22T21:27:19.034Z", "chainId": 56, "syncedBlock": 2000, "headBlock": 2700,
"tiles": { "automations": 3, "activeTasks": 4, "runs24h": 2, "runsSucceeded24h": 1, "runsFailed24h": 1,
"successRate30d": 0.6667, "ethMoved24h": "100000000000000000", "feesPaid24h": "1000000000000000",
"depositTotal": "15000000000000000", "runwayDays": 52.5, "operatorsActive": 1 },
"automations": [ { "address": "0x...d001", "owner": "0x...0e1", "label": "fees -> rewards", "paused": false,
"nativeBalance": "5000000000000000", "allowance": [ { "asset": "0x0000000000000000000000000000000000000000",
"symbol": "ETH", "decimals": 18, "usedInEpoch": "0", "capPerEpoch": "1000000000000000000",
"epochEndsAt": "2026-10-29T00:00:00.000Z" } ], "nextEligibleAt": "2026-10-22T21:26:18.863Z",
"nextEligibleReason": "interval", "lastRunAt": "2026-10-22T20:57:18.870Z", "lastRunStatus": "succeeded",
"runCount": 4, "feeBudgetRemaining": "1500000000000000" } ],
"alerts": [ { "id": "al_ebf536299673ade6", "kind": "fee_budget_low", "severity": "warning", "automation": "0x...d001",
"taskId": null, "message": "fee budget 0.0015 ETH is below 3x the average fee (0.000667 ETH)",
"at": "2026-10-22T21:27:19.034Z", "href": "/app/a/0x...d001" } ],
"operators": [ { "address": "0x...00be", "active": true, "heartbeatAt": "2026-10-22T21:27:18.887Z", "syncedBlock": 2000,
"balance": "30000000000000000", "pendingTxs": 0, "runs24h": 3, "lastError": "out of budget" } ] }| Field | Rule |
|---|---|
syncedBlock | the worker's sync:<chainId> cursor, else the highest automation syncedBlock; null before the first sync |
headBlock | eth_blockNumber (10 s cache, 1.5 s budget), else the indexer's chain_state (56 only), else null |
tiles.runs*24h | runs started in the last 24 h (runs24h counts every status) |
tiles.successRate30d | succeeded / (succeeded + failed) over 30 days, 4 decimals; null with no finished runs |
tiles.ethMoved24h | sum of native ETH outflow deltas of runs started in the last 24 h (wei) |
tiles.depositTotal | sum of the automations' native balances (wei) |
tiles.runwayDays | depositTotal / (feesPaid over the last 7 days / 7), 2 decimals; null when no fee was paid in 7 days |
tiles.operatorsActive | active registry operators (protocol-wide even with owner) |
automations | most recent activity first (lastRunAt, then creation), at most 500; owner lowercase; allowance = the mirrored policy.assets |
nextEligible* | from the timeline: an eligible-now task wins, else the earliest dated task, else the first live task's reason (none without tasks, paused when paused) |
operators | registry operators that are active or have a worker row, plus workers' operators missing from the registry; balance is an RPC read (30 s cache), null when the RPC does not answer; lastError = newest failed run of that operator in 24 h |
Alerts are sorted critical, warning, info, then newest at first. id = al_ + 16 hex, stable for the same condition so the UI can remember dismissals. href is null for alerts without an automation.
| kind | severity | When |
|---|---|---|
fee_budget_low | warning; critical below 1x | active, unpaused automation with feeBudgetRemaining < 3 x the average fee of its last 10 fee-paying runs, or nativeBalance < 2 x the largest maxFeePerRun of its active tasks |
task_parked | warning | failed_attempts >= WORKER_MAX_ATTEMPTS and retry_after in the future (taskId set) |
policy_queued | info | a queued (timelocked) policy update; the message carries the eta |
operator_stalled | critical | a worker heartbeat older than 120 s |
run_failed | warning | a failed run started in the last 24 h (newest 20); href = /app/a/<automation>/runs/run_<n> |
worker_lagging | warning | headBlock - syncedBlock > 600 |
Timeline
Every task, newest automation first, then task id. hours (1-720, default 24) only sets the returned from / to drawing window; tasks due later are still listed.
curl -s "http://127.0.0.1:8787/v1/dashboard/timeline?hours=24"{ "data": [
{ "automation": "0x...d001", "label": "fees -> rewards", "taskId": 0, "taskLabel": "fees -> rewards",
"nextEligibleAt": "2026-10-22T21:57:18.870Z", "reason": "interval", "intervalSeconds": 3600,
"lastRunAt": "2026-10-22T20:57:18.870Z", "eligibleNow": false, "details": {} },
{ "automation": "0x...d003", "label": "fees -> rewards", "taskId": 1, "taskLabel": "checker task",
"nextEligibleAt": null, "reason": "checker", "intervalSeconds": 0, "lastRunAt": null, "eligibleNow": false,
"details": { "unswept": "12", "claimable": "0" } } ],
"from": "2026-10-22T21:27:19.121Z", "to": "2026-10-23T21:27:19.121Z" }Reason order: paused > inactive > parked (nextEligibleAt = retry_after) > interval (lastRunAt + intervalSeconds still in the future) > the worker's cached checkTrigger result (balance => threshold, checker_false / checker_reverted => checker, ok / run_open => eligibleNow: true) > the trigger shape when nothing is cached > unknown. details = the cached eligibility details (pons: claimable, unswept, phase).
Metrics and leaderboard
bucket defaults to hour. to defaults to now and from to 23 hours (hour) / 29 days (day) before to, so the default is 24 / 30 points. Both are truncated to the bucket and every bucket in between is present ("0" when empty). Values are decimal strings. More than 1000 points => 400 bad_request.
curl -s "http://127.0.0.1:8787/v1/metrics/timeseries?metric=runs&bucket=hour"| metric | unit | Value per bucket (by run `started_at`, UTC) |
|---|---|---|
runs (default) | count | runs started |
ethMoved | wei | native ETH outflow deltas |
successRate | ratio | succeeded / (succeeded + failed), up to 4 decimals, "0" when none finished |
gas | gas | sum of gasUsed |
feesPaid | wei | sum of operator fees |
automations | count | cumulative automations created before the end of the bucket |
operators | count | cumulative registry operators active at the end of the bucket; protocol-wide, owner is ignored |
by = runs | volume | fees, window = 24h | 7d | 30d | all. Defaults by=runs, window=7d, limit 20 (max 100). Only automations with at least one run in the window. volume = native ETH outflow; successRate is null when none finished. Ties: more runs, then address.
curl -s "http://127.0.0.1:8787/v1/leaderboard?by=volume&window=7d&limit=10"Worker logs
Newest first; cursor = {id}. limit default 200, max 1000. level is a minimum (warn = warn + error; debug lines exist only when the worker runs with LOG_LEVEL=debug). The worker batches every structured log line into the table each second without blocking its run loop and keeps the newest 5000 rows. loop is sync, run or submit (null for the app).
curl -s "http://127.0.0.1:8787/v1/worker/logs?level=warn&limit=50"{ "data": [ { "id": 123, "at": "2026-10-22T21:27:19.034Z", "level": "warn", "loop": "run", "msg": "run failed",
"fields": { "automation": "0x...d001", "taskId": 0, "runId": 12, "code": "simulation_failed" },
"automation": "0x...d001", "runId": "run_12" } ], "next": "eyJpZCI6MTIzfQ" }Live stream
GET/v1/stream
Server-Sent Events. types (default all of run, automation, worker, log, heartbeat) and owner filter; an unknown type => 400. Each event has an increasing id; reconnect with Last-Event-ID (EventSource does this) or ?lastEventId= to replay the last 500. A heartbeat arrives on connect and every 15 s.
curl -sN "http://127.0.0.1:8787/v1/stream?types=run,heartbeat"Try it listens for 8 seconds or 6 events, whichever comes first. A heartbeat arrives on connect.
retry: 3000
id: 1761168439001
event: heartbeat
data: {"t":"2026-10-22T21:27:19.034Z","headBlock":2700,"syncedBlock":2000}
id: 1761168439002
event: run
data: {"id":"run_12","automation":"0x...d001","taskId":0,"status":"submitted",...,"change":"created"}- Payloads:
run=RunRecord+change(created|updated);automation=AutomationSummary+change;worker= anoperators[]row of the overview;log= one worker log line. workerandheartbeatignoreowner;loglines without an automation only reach streams withoutowner.- At most 200 concurrent streams per API process; above that
503 unavailablewithretry-after: 10. A client that stops reading is disconnected after 1 MB buffered. - The site's live views degrade to polling
/v1/runsand/v1/worker/logsevery 5 s when the stream is unavailable.
Run an operator
An operator watches automations, simulates each due task and submits it inside the owner's rules. Anyone can run one; the leaderboard at /operators lists every operator after its first run on chain.
npx conductor-operator init
npx conductor-operator startinitmakes a new encrypted key on the machine, prints its address, and registers it once it holds the bond and some gas.startruns the operator. Without a bond it stays in dry-run: it simulates and signs nothing.- Registering locks a
0.01 ETHbond in the operator registry. To leave, start an exit (beginExit): the operator stops at once and the bond can be withdrawn after 7 days. No slashing; the registry owner can jail an operator, which only deactivates it. - Heartbeats are signed by the operator key every minute. They are the only source of uptime and are labelled self-reported.
Verified is granted by the registry owner to operators with 50+ runs at 98%+ success in Open mode. It can be revoked.
Fee rules
Operators are paid gas plus a small margin by the automation's owner, from the automation's own ETH, only when a step succeeds. The owner sets the fee policy on chain; Conductor takes no cut and no token is involved.
| Field | Default for new automations | Meaning |
|---|---|---|
gasOverhead | 90000 | gas the contract cannot see (intrinsic, calldata, payout), added to the measured gas |
premiumBps | 2000 | margin on top of gas, in basis points |
flatFee | 0.000005 ETH | a flat amount per paid execute |
maxFeePerExecute | 0.002 ETH | the most one execute can pay |
feeCapPerEpoch | max(0.005 ETH, runs per week x 0.00005 ETH) | the most all executes can pay per fee epoch (7 days), sized to the task's frequency |
Existing automations keep their policy until the owner changes it. If two operators race for the same run the first one lands it; the other transaction reverts and its sender pays the gas.
Who may run an automation
- Verified only (default): operators the registry owner verified, and Conductor's own.
- Any operator: any bonded operator. The owner restricts first, sets the registry mode to open, then lifts the restriction through the policy timelock. Refused for automations whose tasks swap through the Universal Router, run by hand, or cannot be read.
- My list: the automation's own allow list (
setOperatorRestriction+setOperatorAllowed).
Operator API
window=24h|7d|30d|all (default 7d), sort=fees|runs|success|uptime (default fees). Ranking leaves out runs on an operator's own automations (runsExclSelf, feesExclSelfWei); the house operator has house: true and is not ranked. A metric with no data is null, never 0. sources names where each metric comes from: chain, derived or self-reported.
{ "data": [ { "address": "0x...", "label": "relay-north", "house": false, "verified": true, "jailed": false,
"bondWei": "10000000000000000", "firstRunAt": "2026-10-05T10:00:00.000Z", "runs": 236, "runsExclSelf": 236,
"successRate": 0.991, "feesWei": "8100000000000000", "feesExclSelfWei": "8100000000000000", "uptime": 0.97,
"lastHeartbeatAt": "2026-10-23T12:00:00.000Z", "version": "0.1.0", "selfDealingShare": 0, "revokedBy": 0,
"sources": { "runs": "chain", "uptime": "self-reported" } } ] }The row plus series (uptime buckets), automations (served in the window) and the newest runs. 404 for an address that never registered.
GET/v1/operators/:address/runs
The operator's runs, newest first, with limit and cursor like /v1/runs.
Body { operator, chainId, blockNumber, blockHash, version, servedAutomations?, at, signature }: an EIP-191 signature by the operator key over the canonical JSON of the body without signature. Rejected when blockHash is not a canonical block in the last 64 or at is more than 120 s off. The operator client sends these; nothing else needs to.
How rewards work
Rewards for building things people use. A Conductor automation claims the creator fees of $CONDUCTOR (0xdd215018a9c558fcdfb2db56ab4d8c60ddf25491), buys $CONDUCTOR with them in a bounded swap, and calls fund(uint256) on the rewards distributor. What the distributor receives during a round is that round's pool, split between builders by the unique, real users of what they made. The live page is /rewards.
- Round (epoch): one week from 00:00 UTC on the start day. Round 1 runs Saturday 24 Oct to Saturday 31 Oct 2026, and every round after starts where the last ended. It is scored after it closes, the root is posted on chain, and it stays claimable for 8 weeks; unclaimed amounts then go back into the pool.
- Creation: a published recipe, or an automation whose owner turned on "Let others build from this". Conductor's own creations and agent-published recipes are left out.
- Built from: an automation forked from the creation, or created from a recipe or draft that references it.
- Qualifying user: a distinct automation owner, not the builder and not linked to them, whose automation built from the creation completed 3 or more successful on-chain runs in the epoch. Clicks, page views and forks that never run never count.
- Score: the builder's qualifying users across all their creations, each user counted once per builder per epoch.
- Share: score over total score, capped at 25 % of the pool per builder; the excess is shared among the uncapped builders and any leftover rolls into the next pool. A builder's first payout waits one extra epoch.
Rewards are not an investment return. They pay for real usage of what you built, and they can be zero in a round nobody uses it.
Who counts
Sybil resistance decides the program, so linked users are dropped before scoring. A user is linked to a builder when any of these holds:
- the same address as the builder or one of the builder's linked wallets;
- the user wallet's first funding came from the builder or a builder-linked wallet;
- a shared deployer or funder within 2 hops;
- the run was flagged as self-dealing by the sync.
A builder with more than half of their attempted users linked is held for review: no payout that epoch, shown publicly as under review. Every excluded user appears in the epoch JSON with its reason.
The holder gate
To be paid, a builder holds at least HOLD_MIN $CONDUCTOR (default 0.05 % of total supply) as a time-weighted average balance over the epoch, computed from indexed Transfer events and summed across the builder's wallet and up to 2 wallets it links (3 in all). Builders who hold $CONDUCTOR are aligned with the network they're building on. A balance held for one block barely moves a round's average.
Below the gate the score is still shown, the payout is zero and the share goes to the other builders. Holding alone pays nothing: only real usage of what you built does.
Linking a wallet takes two signatures over the same link-wallet owner-auth message: one by the builder, one by the wallet being linked. Users from linked wallets never count for that builder.
Verify and claim
Per epoch Conductor publishes epochs/<n>.json: every builder, creation, qualifying user, excluded user with reason, score, share and amount, and the Merkle tree. The root goes on chain with postEpoch. Anyone can rebuild the tree from the JSON and compare roots.
keccak256(bytes.concat(keccak256(abi.encode(epoch, account, amount))))Claim from /rewards with the wallet the amount was posted for: one epoch calls claim(epoch, account, amount, proof), several call claimMany. Anyone may submit a claim, and it always pays account. Each epoch has a claimDeadline; after it expire(epoch) sends what is left back to the pool. While the distributor is paused, claims wait.
Rewards API
Config values (epoch length, holdMin, minRuns, capBps, claim window, linked-wallet limit) and the distributor address.
Every epoch: epoch, start, end, pool, total, root, uri, status (open, scoring, posted, expired).
The full table for one epoch: each builder's score, qualifying and excluded users, share, amount and status (eligible, capped, under review, below hold, first payout waits). The open epoch answers its scores so far.
GET/v1/rewards/builders/:address
Eligibility now (holder TWAB against holdMin, linked wallets), the current score, unclaimed epochs with their proofs, and past claims.
Qualifying users this round for a recipe slug or an automation address: the "Used by N people this round" line.
{ builder, wallet, chainId, signatures: { builder, wallet } }, each { signer, nonce, issuedAt, signature } over the link-wallet message.
POST/v1/automations/:address/forkable
{ forkable }, owner-signed (action set-forkable, url line on or off): let others build from this automation.
Health
GET/v1/health
ok = database reachable and chain state updated within 60 s. 200 when ok, 503 otherwise, same body.
{ "ok": true, "chainId": 56, "version": "0.1.0", "db": "ok",
"headBlock": 94596923, "safeBlock": 94596911, "headAgeSeconds": 1.4 }curl -s "http://127.0.0.1:8787/v1/health"Stats
GET/v1/stats
Public and cheap. The indexer strip on /data polls it every 10 s.
curl -s "http://127.0.0.1:8787/v1/stats"{ "chainId": 56, "contractsIndexed": 12, "contractsLive": 11,
"eventsIndexed": 1840022, "transfersIndexed": 1530044, "holdersTracked": 40211,
"headBlock": 94596923, "safeBlock": 94596911, "indexedBlock": 94596920, "lagBlocks": 3,
"updatedAt": "2026-10-21T12:00:00.000Z" }Register a contract
POST/v1/contracts
| Field | Required | Notes |
|---|---|---|
address | yes | Must have code, else 422 unsupported. |
kind | no | erc20 | erc721 | abi. Omitted => detected by the indexer. |
abi | for abi | JSON ABI array, max 256 KB. Its events are decoded. For erc20/erc721 it adds extra events. |
startBlock | no | Omitted => the deploy block is found. With a later startBlock, balances are the net change since startBlock, holders are addresses with a positive net, and holders?block= is consistent with that. |
label | no | Max 64 chars. |
curl -s -X POST "http://127.0.0.1:8787/v1/contracts" \
-H "content-type: application/json" \
-d '{"address":"0xa24326d9bcbd110c918dcf386472441b9347cbf6","label":"GITFUND"}'Public registration needs no key (5 per minute per IP). The register form on /data uses it.
Response: 201 (new) or 200 (already registered, unchanged) with ContractInfo. Status moves pending -> backfilling -> live; error carries a reason; paused is set by the operator.
Read contracts
Query: status, kind, limit, cursor. Ordered createdAt DESC, address ASC. Returns Page<ContractInfo>.
curl -s "http://127.0.0.1:8787/v1/contracts?status=live&limit=20"curl -s "http://127.0.0.1:8787/v1/contracts/0xa24326d9bcbd110c918dcf386472441b9347cbf6"{ "address": "0xa24326d9bcbd110c918dcf386472441b9347cbf6", "kind": "erc20", "label": "GITFUND",
"name": "GitFund", "symbol": "GITFUND", "decimals": 18, "totalSupply": "1000000000000000000000000000",
"status": "live", "error": null, "startBlock": 92482929, "indexedThrough": 94596920, "progress": 1,
"holdersCount": 412, "transfersCount": 9811, "eventsCount": 15230, "hasAbi": false,
"createdAt": "2026-10-21T11:58:00.000Z", "updatedAt": "2026-10-21T12:00:00.000Z" }Holders and balances
GET/v1/contracts/:address/holders
Query: block (as-of), limit, cursor. Sorted balance DESC, holder ASC. Zero balances and the zero address never appear. share = balance / sum of all balances at that block. erc721: balance = tokens held.
curl -s "http://127.0.0.1:8787/v1/contracts/0xa24326d9bcbd110c918dcf386472441b9347cbf6/holders?limit=10"{ "contract": "0xa243...cbf6", "block": 94596920, "holderCount": 412, "decimals": 18,
"data": [
{ "holder": "0x5ea3...b5ef", "balance": "120000000000000000000000000", "formatted": "120000000", "share": 0.12 }
],
"next": "eyJiIjoiMTIwMDAw..." }GET/v1/contracts/:address/holders/:holder
Balance of one address. Query: block. A non-holder returns balance: "0" with 200. erc721 adds tokenIds (ascending, max 1000).
curl -s "http://127.0.0.1:8787/v1/contracts/0xa24326d9bcbd110c918dcf386472441b9347cbf6/holders/0x3d8a0670d278c125966f884dc66bea34944a4220"GET/v1/contracts/:address/tokens/:tokenId
erc721 only. Query: block. Returns { contract, tokenId, owner | null, block }. erc20 => 409 conflict.
curl -s "http://127.0.0.1:8787/v1/contracts/0xa24326d9bcbd110c918dcf386472441b9347cbf6/tokens/1"GET/v1/addresses/:address/balances
Every registered erc20/erc721 contract where the address has a non-zero balance. Query: limit, cursor.
curl -s "http://127.0.0.1:8787/v1/addresses/0x3d8a0670d278c125966f884dc66bea34944a4220/balances?limit=20"Transfers
GET/v1/contracts/:address/transfers
erc20 / erc721. Query: address (either side), direction = in | out | any (only with address), tokenId, fromBlock, toBlock (inclusive), order, limit, cursor. Returns Page<Transfer>.
curl -s "http://127.0.0.1:8787/v1/contracts/0xa24326d9bcbd110c918dcf386472441b9347cbf6/transfers?order=desc&limit=20"GET/v1/addresses/:address/transfers
Transfers touching an address across every registered contract. Query: contract, direction, fromBlock, toBlock, order, limit, cursor.
curl -s "http://127.0.0.1:8787/v1/addresses/0x3d8a0670d278c125966f884dc66bea34944a4220/transfers?contract=0xa24326d9bcbd110c918dcf386472441b9347cbf6&order=desc&limit=20"{ "data": [ { "contract": "0xa243...cbf6", "blockNumber": 94596900, "blockHash": "0x...",
"blockTime": "2026-10-21T11:59:58.000Z", "txHash": "0x...", "logIndex": 3,
"from": "0x...", "to": "0x...", "amount": "1000000000000000000",
"tokenId": null, "confirmed": true } ],
"next": null }Events
GET/v1/contracts/:address/events
Every log of the contract, decoded when the ABI matches (undecoded logs have name: null, args: null). Query: event (e.g. Transfer; _unknown = undecoded only), address (matches any address-typed arg), txHash, fromBlock, toBlock, order, limit, cursor.
curl -s "http://127.0.0.1:8787/v1/contracts/0xa24326d9bcbd110c918dcf386472441b9347cbf6/events?event=Transfer&order=desc&limit=20"{ "data": [ { "contract": "0xa243...cbf6", "blockNumber": 94596900, "blockHash": "0x...",
"blockTime": null, "txHash": "0x...", "txIndex": 1, "logIndex": 3,
"name": "Transfer", "signature": "Transfer(address,address,uint256)",
"args": { "from": "0x...", "to": "0x...", "value": "1000000000000000000" },
"topics": ["0xddf252ad...", "0x000...", "0x000..."], "data": "0x...",
"confirmed": true } ],
"next": "eyJuIjo5NDU5NjkwMCwiaSI6M30" }args is keyed by ABI input name (index string when unnamed); integers as decimal strings, addresses lowercase, bytes as hex, arrays and tuples as arrays and objects.
Snapshots
GET/v1/contracts/:address/snapshot
The full holder list at a block, for airdrops and holder-gated payouts. Not paginated. Query: block (default min(safeBlock, indexedThrough)), format = json | csv, minBalance (raw units, default 1). Too many holders => 409 conflict. Snapshots at or below the safe block are immutable and may be cached.
curl -s "http://127.0.0.1:8787/v1/contracts/0xa24326d9bcbd110c918dcf386472441b9347cbf6/snapshot?format=json"Set format to csv in the Try-it panel and every tab switches to the CSV download.
{ "contract": "0xa243...cbf6", "kind": "erc20", "symbol": "GITFUND", "decimals": 18, "block": 94596911,
"holderCount": 412, "total": "1000000000000000000000000000",
"rows": [ { "holder": "0x5ea3...b5ef", "balance": "120000000000000000000000000", "formatted": "120000000" } ] }holder,balance,formatted
0x5ea3...b5ef,120000000000000000000000000,120000000erc721 rows add tokenIds (JSON) or a token_ids column with ids separated by ; (CSV). Rows are sorted balance DESC, holder ASC.
Manage webhooks
A webhook subscribes to logs of one registered contract, optionally narrowed to one event name and/or one address (matched against any address-typed argument). Every route needs write auth.
| Method | Path | Body / query | Response |
|---|---|---|---|
| POST | /v1/webhooks | { contract, url, eventName?, address?, description?, fromBlock? } | 201 with secret, shown once |
| GET | /v1/webhooks | contract?, limit, cursor | Page<Webhook> |
| GET | /v1/webhooks/:id | Webhook | |
| PATCH | /v1/webhooks/:id | { active?, url?, description? } | Webhook; active: true resets failures |
| DELETE | /v1/webhooks/:id | 204 | |
| GET | /v1/webhooks/:id/deliveries | limit, cursor | Page<WebhookDelivery> |
| POST | /v1/webhooks/:id/test | 200 WebhookDelivery (sends a ping) |
Create
curl -s -X POST "http://127.0.0.1:8787/v1/webhooks" \
-H "x-api-key: $CONDUCTOR_API_KEY" \
-H "content-type: application/json" \
-d '{"contract":"0xa24326d9bcbd110c918dcf386472441b9347cbf6","url":"https://example.com/hooks/conductor","eventName":"Transfer"}'contract must be registered (404). url must be http(s); https only in production; private and loopback hosts are rejected. eventName must exist in the ABI when it is known (400). fromBlock defaults to the current chain head; pass a lower value, e.g. 0, to also receive history. The webhook object returns it as fromBlock.
List, read, enable, test
The reads need your key too: paste it into the Try-it panel. It is sent with that one request and never stored.
curl -s "http://127.0.0.1:8787/v1/webhooks?contract=0xa24326d9bcbd110c918dcf386472441b9347cbf6&limit=20" \
-H "x-api-key: $CONDUCTOR_API_KEY"curl -s "http://127.0.0.1:8787/v1/webhooks/wh_3f9a0c1d2e4b5a6978877665" \
-H "x-api-key: $CONDUCTOR_API_KEY"curl -s -X PATCH "http://127.0.0.1:8787/v1/webhooks/wh_3f9a0c1d2e4b5a6978877665" \
-H "x-api-key: $CONDUCTOR_API_KEY" \
-H "content-type: application/json" \
-d '{"active":true}'curl -s "http://127.0.0.1:8787/v1/webhooks/wh_3f9a0c1d2e4b5a6978877665/deliveries?limit=20" \
-H "x-api-key: $CONDUCTOR_API_KEY"curl -s -X POST "http://127.0.0.1:8787/v1/webhooks/wh_3f9a0c1d2e4b5a6978877665/test" \
-H "x-api-key: $CONDUCTOR_API_KEY"Deliveries
- Only confirmed logs are delivered: the POST goes out once the head is 12 blocks past the log (about 1-2 s on 56).
- A reorg before delivery deletes the pending delivery. After delivery the row is marked
reverted; no retraction is sent in v0. - A webhook only delivers logs with
blockNumber >= fromBlock.fromBlockdefaults to the chain head at creation, so history is never replayed, not during a backfill and not on a later re-index. Opt in with"fromBlock": 0(or any block) on create to also receive history the indexer writes after that; expect one POST per log. - Order is best effort. Use
event.blockNumberandevent.logIndexto order.
POST <your url>
content-type: application/json
user-agent: conductor-webhooks/0.1
x-conductor-webhook-id: wh_3f9a0c1d2e4b5a6978877665
x-conductor-delivery-id: dlv_5512
x-conductor-event: Transfer
x-conductor-signature: t=1792617600,v1=5b0e...c4{
"id": "dlv_5512", "type": "event", "webhookId": "wh_3f9a0c1d2e4b5a6978877665",
"attempt": 1, "sentAt": "2026-10-21T12:00:03.120Z", "chainId": 56,
"contract": "0xa24326d9bcbd110c918dcf386472441b9347cbf6",
"event": { "blockNumber": 94596900, "txHash": "0x...", "logIndex": 3,
"name": "Transfer", "args": { "from": "0x...", "to": "0x...", "value": "1000000000000000000" },
"confirmed": true, "...": "same fields as the events API" },
"transfer": { "from": "0x...", "to": "0x...", "amount": "1000000000000000000", "tokenId": null, "...": "..." }
}transfer is non-null only for Transfer logs of erc20/erc721 contracts. id is stable across retries: dedupe on it. Test pings send { "type": "ping", "id": "ping_...", ... } with x-conductor-event: ping.
Verify signatures
x-conductor-signature: t=<unix seconds>,v1=<hex HMAC-SHA256> over ${t}.${rawBody}, keyed with the whole whsec_... secret as UTF-8. Verify the raw body before parsing it, reject when |now - t| > 300, and compare in constant time. Every retry is re-signed with a fresh t.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, rawBody, header, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=").map((s) => s.trim())));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return expected.length === parts.v1?.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}The SDK exports the same verifySignature(secret, rawBody, header, tolerance?), built on WebCrypto, for Node and browsers.
Retries and disabling
- Success = any 2xx within 10 s. The first 1024 bytes of the response are logged.
- Anything else (non-2xx, timeout, network error, a redirect) is a failed attempt. Redirects are not followed.
- Up to 8 attempts. Delays: 10 s, 30 s, 2 m, 10 m, 30 m, 1 h, 2 h, plus 0-10 % jitter.
410 Gonedisables the webhook immediately.- 20 consecutive failed deliveries disable it. Re-enable with
PATCH /v1/webhooks/:id {"active": true}; failures while disabled are not replayed. - Every attempt, pings included, is listed at
GET /v1/webhooks/:id/deliveries.
Alert webhooks
Signed HTTP alerts about your automations: run results, parked tasks, a low fee budget or deposit, a stalled operator, policy and pause changes. They are managed by the owner wallet, not an API key: each request carries an EIP-191 signature, or a short session opened with one. Delivery, signing and retries are the same as contract webhooks.
| Scope | Created at | Receives |
|---|---|---|
automation | /v1/automations/:address/webhooks | Events of that one automation, while the signer still owns it. |
owner | /v1/owners/:address/webhooks | Events of every automation the address owns, plus operator.stalled for operators that ran them. |
| Method | Path | Body / query | Response |
|---|---|---|---|
| POST | P | { url, events?, depositLowRuns?, description? } | 201 with secret, shown once |
| GET | P | limit (<= 200), cursor | Page<Webhook> |
| GET | P/:id | Webhook | |
| PATCH | P/:id | { url?, events?, depositLowRuns?, description?, active? } | Webhook |
| DELETE | P/:id | 204 | |
| POST | P/:id/test | 200 WebhookDelivery (a ping; once per 10 s) | |
| GET | P/:id/deliveries | limit (default 20), cursor | Page<WebhookDelivery> |
| POST | /v1/owners/:address/sessions | {} | 201 { token, owner, chainId, expiresAt } |
Pis/v1/automations/:address/webhooksor/v1/owners/:address/webhooks.events: any of the 12 types below; empty or omitted = every type. An unknown type is400withdetails.valid.depositLowRuns: theautomation.deposit_lowthreshold in runs the deposit still covers, 1 to 10000, default 24.- At most 10 alert webhooks per owner (
422 limit_reached). An:idfrom another path is404. - Events that happened before a webhook was created are never sent.
Event catalogue
| Type | Fires when | Severity |
|---|---|---|
run.succeeded | A run finishes successfully. | info |
run.failed | A run fails. | warning |
run.partial | A multi-step run advances with steps left (once per step). | info |
task.parked | A task reaches the failure limit and is parked until a retry time. | warning |
automation.fee_budget_low | Fee budget left is under 3 average runs (critical under 1). | warning / critical |
automation.deposit_low | The ETH deposit covers fewer runs than depositLowRuns. | warning |
operator.stalled | An operator's heartbeat is 120 s to 24 h old. | critical |
policy.queued | A timelocked policy update is queued. | info |
policy.executed | A queued policy update is executed. | info |
automation.paused | The automation is paused. | warning |
automation.resumed | The automation is unpaused. | info |
proposal.created | A connected agent proposed a change. Fixed summary and a review link; never the agent's note. | info |
finding.opened | Conductor's manager flagged something. Set summary and a link to the Manager panel; never model text. | the finding's severity |
- Budget, deposit and stall alerts fire once when the condition starts and re-arm when it clears. They are checked every 30 s.
- The average run fee is the mean of the last 10 paid runs, else the largest
maxFeePerRunof the active tasks. skipped,abortedandstuckruns do not alert. Nothing is retracted after a reorg.
{
"type": "automation", "id": "dlv_8812", "webhookId": "wh_9c1e0b7a5d3f2e1c0b9a8f7e",
"attempt": 1, "sentAt": "2026-10-23T09:14:05.310Z", "chainId": 56,
"event": {
"id": "evt_4410", "type": "run.failed", "chainId": 56, "taskId": 0,
"occurredAt": "2026-10-23T09:14:04.900Z", "severity": "warning",
"automation": "0x5b1f00000000000000000000000000000000a001",
"owner": "0x3d8a0670d278c125966f884dc66bea34944a4220",
"summary": "fees -> rewards run_381 failed at step 2/3: swap minOut not met",
"href": "https://useconductor.xyz/app/a/0x5b1f00000000000000000000000000000000a001/runs/run_381",
"data": { "runId": "run_381", "status": "failed", "errorCode": "slippage", "txs": [...], "...": "RunEventData" }
}
}The header x-conductor-event carries event.type. id is stable across retries of one delivery; event.id is stable for the event. Dedupe on event.id when two of your webhooks can receive the same event.
Owner auth
Sign this message with the owner wallet (personal_sign, EOA only) and send it as x-conductor-signer, x-conductor-nonce (32 hex), x-conductor-issued-at (unix seconds) and x-conductor-signature. One signature per request; each nonce works once and expires after 300 s.
Conductor alerts
domain: useconductor.xyz
action: create-webhook
scope: automation
address: 0x5b1f00000000000000000000000000000000a001
webhook: -
url: 0x<keccak256 of the url>
events: run.failed,task.parked
chainId: 56
nonce: 0123456789abcdef0123456789abcdef
issuedAt: 1792800000action:create-webhook,update-webhook,delete-webhook,read-webhooks,test-webhookoropen-session.webhookis the:idor-.urlandeventscome from the body on create and update (events sorted,*for an empty list) and are-otherwise.- A session (
authorization: Bearer cses_..., one hour) covers reads and test pings for the owner and the automations it owns. Creating, changing and deleting always need a fresh signature. 401 unauthorizedcarriesdetails.reason:missing,expired,bad_signature,replayedorsession_invalid. A valid signature from another wallet is403 forbidden.
import { Conductor, ownerSigner } from "@conductor/sdk";
import { privateKeyToAccount } from "viem/accounts";
const conductor = new Conductor({ baseUrl: process.env.CONDUCTOR_API_URL, chainId: 56 });
const owner = ownerSigner(privateKeyToAccount(process.env.CONDUCTOR_OWNER_PRIVATE_KEY));
const target = { automation: "0x5b1f00000000000000000000000000000000a001" };
const hook = await conductor.createAlertWebhook(target, { url: "https://relay.example.com/", events: ["run.failed", "task.parked"] }, owner);
const session = await conductor.openOwnerSession(owner.signer, owner);
const page = await conductor.listAlertWebhooks(target, {}, { session: session.token });export CONDUCTOR_OWNER_PRIVATE_KEY=0x... # or --owner-key-file owner.key
conductor alerts events
conductor alerts create https://relay.example.com/ --automation 0x5b1f00000000000000000000000000000000a001 --events run.failed,task.parked --deposit-low-runs 48
conductor alerts list --owner 0x3d8a0670d278c125966f884dc66bea34944a4220
conductor alerts test <id> --automation 0x5b1f00000000000000000000000000000000a001
conductor alerts deliveries <id> --automation 0x5b1f00000000000000000000000000000000a001Relay to Discord
A small receiver that checks the signature with the SDK and posts summary and href to a Discord channel webhook. A non-2xx answer makes Conductor retry.
import { createServer } from "node:http";
import { verifyWebhookSignature, type WebhookPayload } from "@conductor/sdk";
const secret = process.env.CONDUCTOR_WEBHOOK_SECRET!;
const discord = process.env.DISCORD_WEBHOOK_URL!;
const seen = new Set<string>();
createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
const raw = Buffer.concat(chunks).toString("utf8");
if (!(await verifyWebhookSignature(secret, req.headers["x-conductor-signature"] as string, raw))) {
res.writeHead(401).end("bad signature");
return;
}
const body = JSON.parse(raw) as WebhookPayload;
if (body.type === "automation" && !seen.has(body.event.id)) {
seen.add(body.event.id);
const r = await fetch(discord, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ content: `${body.event.summary} ${body.event.href}` }),
});
if (!r.ok) return void res.writeHead(502).end("discord failed");
}
res.writeHead(200).end("ok");
}).listen(Number(process.env.PORT ?? 8788));Bring your own agent
Connect Claude Code, Claude Desktop, ChatGPT or your own bot to Conductor with a key the automation's owner grants. The agent reads automation data and proposes structured changes. Conductor rebuilds each proposal from its own mirror, checks it against hard ceilings and the live policy, simulates it, and puts it in the owner's inbox. The owner signs on this site. Agents never sign, never receive sendable calldata and never move funds.
- A proposal is a kind plus a few params. Nothing changes on chain until the owner's wallet signs the transactions Conductor prepares from it.
- What the owner reviews is rendered from the decoded calldata and the simulation, never from the agent's text. The site checks each transaction's calldata hash before sending it.
- Agent notes are untrusted plain text (at most 500 characters), shown only as
Note from "<name>" · unchecked. appliedmeans the server verified the receipt on chain.
Conductor's manager
Conductor's manager watches each automation, explains every run from on-chain facts, and suggests changes as transactions you review. It never signs, holds keys or moves funds.
- Findings: one line each, written from facts code computed (runs, balances, caps, the fee budget, operator health, queued policy changes, the pool and hook). Severity is
critical,warningorinfo. One open finding per automation, detector and subject; it clears on its own when the condition goes away. - Suggestions: an actionable finding links a proposal with proposer
manager(agent.name"Conductor manager", no key). Same kinds, ceilings, checks and simulation as a connected agent's; the owner reviews and signs it in the same sheet. - Run explanations: one line per run, labelled "Written from on-chain facts".
sourceistemplate(default) ormodel(reworded, rejected if it adds a number, address or action). - Mute per automation with the owner-auth action
set-manager(url linekeccak256("mute" | "unmute")). Dismissing a finding mutes it for 7 days. If the manager is off, automations keep running.
| Method | Path | Auth | Does |
|---|---|---|---|
| GET | /v1/automations/:address/manager | public | { muted, openFindings, bySeverity } |
| GET | /v1/automations/:address/findings | public | findings, ?status=open|resolved|dismissed |
| POST | /v1/automations/:address/manager | owner signature (set-manager) | { muted } |
| POST | /v1/findings/:id/dismiss | owner session | dismiss; not raised again for 7 days |
| GET | /v1/runs/:id/explanation | public | { text, source }; also inline as explanation on run reads |
Webhook event finding.opened carries a set summary (Conductor's manager flagged: <title>), never model text.
Keys and scopes
Keys look like cagt_<64 hex>. They are shown once, stored as a hash, cover 1-20 automations of one owner, expire after 7, 30 (default) or 90 days and can be revoked in one click. At most 10 live keys per owner. Ownership is re-checked on every call. A missing, revoked or expired key is a 401, never a fallback to anonymous access.
| Scope | Allows |
|---|---|
read | Reads this automation's runs, policy, tasks and balances. It can't suggest anything. |
propose:limited | Reads, and can suggest: pause, turn a task off, revoke an operator, lower caps, cancel a queued rule change. You sign each one. |
propose | Everything above, and can also suggest: raise caps (at most 2×, at most 0.1 ETH a day), deposit gas money (at most 0.05 ETH), lengthen a task's interval, apply a queued change. You sign each one. |
There is no operate scope. No kind exists for withdrawing, ownerCall, ownership, adding a recipient or target, unpausing, turning a task on, or shortening a delay or interval.
| Method | Path | Auth | Does |
|---|---|---|---|
| POST | /v1/owners/:address/agent-keys | owner signature (grant-agent-key) | grant; { key, token }, the token in this response only |
| GET | /v1/owners/:address/agent-keys | signature or session | list keys, never tokens |
| DELETE | /v1/owners/:address/agent-keys/:id | signature or session | revoke |
| GET | /v1/agent-keys/me | agent key | the calling key's info |
The grant body is { name, scopes, automations, expiresInDays }. Its hash, keccak256 of the canonical JSON (keys sorted, automations lowercase and sorted), goes in the signed message's url line, so the signature covers exactly what is granted.
MCP server
Remote: POST https://useconductor.xyz/mcp, stateless Streamable HTTP with JSON responses. Local: npx conductor-mcp over stdio with CONDUCTOR_AGENT_KEY; it holds no wallet. Auth: Authorization: Bearer cagt_.... With no key it serves whoami and three public reads (list_automations, get_automation, list_runs); simulate_task, create_proposal and get_proposal need a key. Third-party text sits under external / untrusted fields.
| Tool | Does |
|---|---|
whoami | The key's name, owner, automations, scope and expiry. |
list_automations | Automations; with a key, the ones it was granted. |
get_automation | One automation: policy, tasks, balances, operator, recent state. |
list_runs | Recent runs with facts Conductor reads from chain. |
simulate_task | Simulate a task at the chain head. Results only, no calldata. Needs a key. |
create_proposal | Suggest one change. Returns its id, status, review link and expiry. Needs a propose scope. |
get_proposal | A suggestion's status and the change Conductor rendered from it. |
Proposal lifecycle
create -> checks -> open --owner prepares + signs--> applied (receipt verified)
| partially_applied (only part landed)
+--loosening under a policy delay--> queued --eta, second signature--> applied
side exits: dismissed (owner) · withdrawn (the agent) · expired · invalid (fails its checks when revalidated)| Status | Meaning |
|---|---|
open | checked and simulated, waiting for the owner |
queued | the owner signed the queue step; waiting for the eta and the second signature |
applied | a verified receipt matches the prepared transactions |
partially_applied | a multi-transaction proposal landed only in part; the site offers the rest |
dismissed | the owner said no; the same proposal from the same key is muted for 7 days |
withdrawn | the creating key withdrew it |
expired | not signed in time: 72 hours (pause: 24 hours) |
invalid | the automation changed and the proposal no longer passes its checks |
| Method | Path | Auth | Does |
|---|---|---|---|
| POST | /v1/automations/:address/proposals | agent key (propose*) | create; accepts idempotency-key |
| GET | /v1/automations/:address/proposals | owner session, or the key (its own) | list, ?status= |
| GET | /v1/proposals/:id | owner, or the creating key | one proposal with its diff and checks |
| POST | /v1/proposals/:id/prepare | owner session (prepare-proposal) | revalidate; the owner's TxRequest[] and calldata hashes |
| POST | /v1/proposals/:id/applied | owner | report a tx hash; the server verifies the receipt |
| POST | /v1/proposals/:id/dismiss | owner session (dismiss-proposal) | dismiss (7-day mute) |
| POST | /v1/proposals/:id/withdraw | the creating key | withdraw |
Timelocked changes take two signatures: the owner queues the change, then applies it after the eta. No kind shortens the delay.
Agent setup
Create a key in Connected agents on your automation's page, then add Conductor to your agent.
Claude Code
claude mcp add --transport http conductor https://useconductor.xyz/mcp --header "Authorization: Bearer cagt_<your key>"Claude Desktop
{
"mcpServers": {
"conductor": {
"command": "npx",
"args": [
"-y",
"conductor-mcp"
],
"env": {
"CONDUCTOR_AGENT_KEY": "cagt_<your key>"
}
}
}
}ChatGPT
- Settings → Connectors → Advanced → turn on developer mode.
- Create a connector. Name: Conductor.
- Server URL: https://useconductor.xyz/mcp
- Authentication: No authentication.
ChatGPT and claude.ai custom connectors connect without a key today, so they read public data only and can't propose. That changes when Conductor supports OAuth.
SDK and CLI
The TypeScript SDK has one method per route, async iterators for paginated reads, ConductorError (status, code, message), and verifySignature. Zero dependencies.
import { Conductor } from "@conductor/sdk";
const conductor = new Conductor({ baseUrl: process.env.CONDUCTOR_API_URL });
const stats = await conductor.stats();
for await (const holder of conductor.iterateHolders("0xa24326d9bcbd110c918dcf386472441b9347cbf6")) {
console.log(holder.holder, holder.formatted);
}The CLI reads CONDUCTOR_API_URL and CONDUCTOR_API_KEY (or --api, --key).
conductor register 0xa24326d9bcbd110c918dcf386472441b9347cbf6 --label GITFUND --wait
conductor status 0xa24326d9bcbd110c918dcf386472441b9347cbf6
conductor holders 0xa24326d9bcbd110c918dcf386472441b9347cbf6 --limit 20
conductor snapshot 0xa24326d9bcbd110c918dcf386472441b9347cbf6 --block 94596911 --csv --out holders.csv
conductor webhook add|ls|rm|test ...