CONDUCTOR
Wallet

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.

shell
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

TopicRule
AddressesAccepted in any case. Always returned lowercase. Invalid => 400 invalid_address.
uint256balance, amount, tokenId, totalSupply, total are decimal strings in raw units. formatted = raw / 10^decimals, no rounding.
NumbersBlock numbers and counts are JSON numbers.
TimesISO-8601 UTC strings. blockTime may be null.
confirmedblockNumber <= 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.
limitDefault 100. Max 1000 for holders, events, transfers, balances; 200 for contracts, webhooks, deliveries. Out of range is clamped.
orderdesc (default, newest first) or asc, for events and transfers.
blockOptional as-of block on holder, balance, owner and snapshot reads. Must satisfy startBlock - 1 <= block <= indexedThrough, else 409 not_indexed.
Kindserc20, erc721 (holders = token counts), abi (events only; holder endpoints => 409 conflict).

Errors

json
{ "error": { "code": "not_found", "message": "contract 0xabc... is not registered" } }
HTTPcodeWhen
400bad_request, invalid_address, invalid_cursorValidation.
401unauthorizedWrite route, keys configured, x-api-key missing or unknown. Alert routes: no valid signature or session (details.reason).
403forbiddenAlert routes: the signature or session is valid but not from the owner.
404not_foundUnknown contract, webhook or route.
409conflictEndpoint not valid for the contract kind; snapshot larger than the row limit.
409not_indexedblock outside the indexed range. Details carry indexedThrough and startBlock.
422unsupportedAddress has no code; kind not detectable and no ABI; ABI invalid.
422limit_reachedContract or webhook limit reached.
429rate_limitedPer-IP limit. x-ratelimit-* and retry-after headers are set.
500internalBug.
503unavailableDatabase unreachable.

Auth and limits

  • Reads need no auth.
  • Writes (POST /v1/contracts and every /v1/webhooks route) take x-api-key: <key> when the operator configures keys. With no keys configured, writes are open.
  • POST /v1/contracts also works without a key when public registration is on (the default), limited to 5 per minute per IP. The register form on /data uses 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 Automation contract 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).

ModeReady when
AnyAlways. Carries the poolId so the worker can wake the task on that pool's events.
PriceOutsideRangetick < tickLower or tick >= tickUpper.
PriceInsideRangetickLower <= tick < tickUpper.
LiquidityBelowliquidity < liquidityThreshold.
LiquidityAboveliquidity > liquidityThreshold.
  • A pool that was never initialised (sqrtPriceX96 == 0) is not ready in any mode except Any.
  • The range modes revert InvalidRange() when tickLower >= 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.

MarkerWakes on
v4.pool.swapSwap on the PoolManager for the task's pool.
v4.pool.liquidityModifyLiquidity for the task's pool.
v4.pool.initializeInitialize 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.anyAny 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.

IdNameStatusWhat it doesTrigger
pons-rewards/v1Claim hook fees -> fund rewardsliveSweep the curve, sweep the hook's pool fees, claim the escrow, swap on v4, send to rewards.PonsFeeChecker, pons.escrow.credited
v4-rebalance/v1Rebalance when price leaves the rangeplannedCollect, burn the position, mint around the current tick.PriceOutsideRange, v4.pool.swap
v4-oracle-refresh/v1Refresh a hook's oracleplannedCall hook.update() (owner-chosen selector).interval + v4.pool.swap
v4-dynamic-fee/v1Step a dynamic fee with volumeplannedhook.setFee(poolId, fee) from a volume band.interval, v4.pool.swap
v4-twamm/v1Run a large order in slicesplannedUniversal Router swap of a fixed slice.interval + PriceInsideRange
v4-liquidity-floor/v1Top up a pool below a liquidity floorplannedMint liquidity from the automation's balance.LiquidityBelow, v4.pool.liquidity
blankBlank automationliveCreate 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).

RouteReturns
GET /v1/hooks?limit&cursorHooks, most pools first: address, flags, permissions, pools, swaps in 24 h, first seen, known name.
GET /v1/hooks/:addressOne hook and its pools, newest first.
GET /v1/pools?hook=&limit&cursorPools: currencies, fee (dynamic or fixed), tick spacing, hook, tick, liquidity, swap count.
GET /v1/pools/:poolIdOne pool with its last 20 swaps and live tick, sqrtPriceX96, liquidity, lpFee from StateView (null when the RPC fails).

GET/v1/hooks?limit=&cursor=

GET/v1/hooks/:address

GET/v1/pools?hook=&limit=&cursor=

GET/v1/pools/:poolId

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.
BlockInputs (limits)What it does
dynamicFeebase 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 hthe LP fee rises with recent price movement and eases back to the base
hookFeecreator, builder (up to 50 bps, paid to the deploying wallet) and treasury shares, 1 to 300 bps in totala fee in the input currency on every swap, kept in the hook per recipient; distribute() pays only the recipients set at deploy
maxSwapup to 10% of the input reserves, or a set amountrefuses any swap above the cap
launchGuard1 second to 7 days, a cap of the same shapean 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.

POST/v1/hook-studio/compose

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.

POST/v1/hook-studio/simulate

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.

POST/v1/hook-studio/prepare

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

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 0x2080 or 0x2088). 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.

RuleCandidateApplies whenAuto-publish
pons-fees-rewardslaunch tokena pons meme hook pool with 20 or more swapsyes
swap-count-payoutpoolthe hook has afterSwap and 10 or more swaps in 24 hyes
price-range-actionpoolthe pool has liquidity and a known tickyes
dynamic-fee-steppoolthe pool uses a dynamic feenever: waits for review
  • Two engines: init substitutes input words into an AutomationInitJson; pons-rewards merges inputs into the fees -> rewards params.
  • Each task's templateId is keccak256(templateName): recipe/<slug> for init, pons-rewards/v1 for pons-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; $owner is 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.

GET/v1/recipes/:slug

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.

GET/v1/recipes/:slug/forks

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) with recipe: { 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>" (forkedFrom on /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.
ts
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);
shell
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-a24326

Agents feed

AgentRunsDoes
conductor.scanevery 10 minapplies the rules; a new candidate becomes a draft recipe, a changed one a new version
conductor.simulateevery 2 minsimulates unsimulated versions at latest with each input's simulation value; checks guardrails
conductor.publishafter each simulationpublishes when the rule allows it, the simulation is ok and every guardrail passes; else holds it for review
conductor.refreshdailyre-simulates published versions; three failures in a row flag the recipe

GET/v1/agents/runs

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.

GET/v1/agents/jobs/:id

AgentJob (job_ + 24 hex): agent, kind, status, inputs, outputs, log lines and the block it ran at. Jobs are kept 30 days.

shell
conductor agents runs --agent conductor.publish --limit 10
conductor agents runs --recipe pons-fees-rewards-gitfund-a24326
conductor agents job job_0123456789abcdef01234567

Review

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.

POST/v1/recipes/:slug/publish

POST/v1/recipes/:slug/retire

  • Body { version?, note, simulationWaiver? }. Publishing needs a passing simulation unless simulationWaiver explains why; the waiver is shown on the recipe page.
  • The signed message is the alerts one with action: publish-recipe or retire-recipe, scope: owner, address = the signer and url = keccak256 of <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.
text
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: 1792800000
shell
export 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) or Conductor default.
  • Each limit says who holds it: Enforced on chain means the contract rejects anything over it, whoever asks. Operator rule means 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.
StatusMeans
readycompiled, every guardrail passed, simulated (or skipped with a warning): you can sign
needs_inputa question comes back with quick answers; answering is a refinement
blockedcompiled, but a guardrail or the simulation failed; the plan says which
unsupportednothing in the sentence can be built yet; the answer names the closest thing that works
refusedthe 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.

GET/v1/compose/:draftId

The latest proposal and its turns. Drafts expire after 24 hours unless signed.

  • Sign with POST /v1/automations/prepare-create and { 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_unavailable or daily_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.

RoleWhoCan
Owneryour wallet (an EOA or a Safe)everything on its automation: tasks, policy, pause, revoke operators, withdraw any asset, ownerCall
Operatora keeper key in the OperatorRegistry (v0: Conductor's worker)execute tasks within the rules, abortRun after a timeout, get paid a capped fee
Protocol ownerdeployer key, then a multisigadd operators and set the implementation for NEW automations; never moves your funds
Anyonesimulate, 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: withdraw and ownerCall ignore 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.

ChangeApplies
Lower a cap, remove a target or recipient, restrict operators, raise the timelockimmediately
New target, higher cap, new recipient, looser fee policy, shorter timelockimmediately 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.

DefaultValue
maxGasPrice1 gwei (56 runs near 0.02 gwei)
premium20 %
max per execute0.002 ETH
budget per 7-day fee epoch0.02 ETH
L1 feeincluded (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).

TriggerHolds when
intervalat least N seconds since the last run started
balance thresholdan asset balance of the automation (or another holder) is at least X
checkera stateless checker returns true, e.g. PonsFeeChecker: claimable creator fees >= X
nonemanual: 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.

The policy engine: execute, trigger, then for each step template binding, call check, call and balance deltas, then caps and recipients, then the operator fee.for each stepexecute()operator authorized, task activetriggerinterval, threshold, checker (run start)template bindingbytes outside slots fixed; slots resolvedcall checktarget + selector, value, validatorcalltarget.call{value}(data), no delegatecallbalance deltaspre/post per tracked assetcaps + recipientsepoch + per-run caps, recipientsoperator feecapped, never reverts the run
One transaction. Solid arrows run in order; the dashed loop repeats the step stages for every call of the task. Any check that fails reverts the whole transaction; only allowFailure steps may fail on their own.
StageRuleRevert
Authorize!paused && registry.isActiveOperator(op) && !revoked[op] && (!operatorRestricted || allowed[op]); the task exists and is active.NotAuthorizedOperator, TaskNotFound, TaskInactive
TriggerAt run start only (nextStep == 0): every non-zero condition holds (interval, balance threshold, checker). All-zero = manual.TriggerNotMet(reason)
BindSame 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
Calltarget.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)
DeltasEvery tracked asset (max 16, ETH first) is read before and after every call; gross inflow and outflow are summed per asset.
CapsOutflow 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
Feemin(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.

OpTightening (immediate)Loosening (timelocked when policyDelay > 0)
SET_TARGETadds a validator where none existed, allowValue not increasednew permission, allowValue false -> true, validator changed or removed
REMOVE_TARGETalways
SET_ASSETlower cap, same epochLengthnew asset, higher cap, any epochLength change
REMOVE_ASSETalways
SET_RECIPIENTallowed = false, or allowed = true for an address already allowed (no-op)allowed = true for an address not yet allowed
SET_FEE_POLICYevery limit equal or lower, feeEpochLength equal or longer, includeL1Fee not enabledany limit higher, epoch shorter, includeL1Fee false -> true
SET_OPERATOR_RESTRICTIONfalse -> truetrue -> false
SET_OPERATOR_ALLOWEDallowed = false, or allowed = true for an operator already allowed (no-op)allowed = true for an operator not yet allowed
SET_POLICY_DELAYincreasedecrease

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).

Factory per the API—
API chain · synced block—
Deploy block (record)95,393,762

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.

Conductor contracts on chain 56
ContractAddress · EtherscanRole
AutomationFactorychecking0xf5ca3e665269ab3fa5da098c093c58fabf5a746fcreates automations (EIP-1167 clones) and indexes them by owner
Automation (implementation)0x754afeb54c37756db86e110df90289ce12a97996the code every automation clone runs; initializers disabled
OperatorRegistry0x5f19edd6447af259bdffe8c847ec4e9f516be085allowlist of operators with an active flag
IntervalChecker0x6275074a33492d8da99d9e6a9b73a0c5ad1bb36bstateless: at least N seconds since the last run
BalanceThresholdChecker0x93cc92e8aa4d26dd0b9c12d9a740cd56a4aec9dastateless: a balance is at least X
PonsFeeChecker0x4fa61399164b06b5ca4499d6eac3b9b237782905stateless: claimable pons creator fees are at least X
UniversalRouterV4Validator0x8fecea6e128ab91d89102ecad7b40324398335b8the only accepted Universal Router call shape
PonsRewardsFlowBuilder0x5228b68010acb6d3f926e44d3ee2fbac64debb8ccanonical encoder of the fees -> rewards task (view)

Called by the fees -> rewards template (verified addresses)

pons v2 and Uniswap v4 contracts on chain 56
ContractAddress · EtherscanRole
pons v2 factory0x7ed598bcef8bd9edd8c97a195c6d13f40801ec7elaunch records; transferCreatorFeeRecipient
pons v2 meme hook0xe5e702641ea86f4ae6cc3cdaed2b886f976be044pool hook; sweepPoolFees
pons v2 fee escrow0xd3afeb2a57f70ef218aa82451c51b2fb0416ac9ecreator fee escrow; claim()
Uniswap v4 Universal Router0x8876789976decbfcbbbe364623c63652db8c0904the swap step (V4_SWAP, exact in)
Uniswap v4 PoolManager0x8366a39cc670b4001a1121b8f6a443a643e40951pools of graduated pons launches
Uniswap v4 V4Quoter0x987e643e0d2b5a2b56ab52a0c0036c508d3451faquotes 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.

#CallOn failureFilled at run time
0curve.sweepFees(0)continue—
1memeHook.sweepPoolFees(poolId, 0, 0)continue—
2feeEscrow.claim()continue—
3universalRouter.execute{value}(V4_SWAP, ...)revert allvalue = ETH balance - reserve; minOut by the operator (quote - slippage); deadline = now + 300
4rewardToken.transfer(target, balance) or approve + fund(uint256)revert allamount = reward token balance
FieldMeaningDefault
feeTokenthe pons v2 launch whose creator fees are claimed (ETH-quoted)required
rewardTokentoken bought with the fees: a graduated (phase 2) ETH-quoted launch; may be the same tokenrequired
rewardsTargetrewards contract, distributor or wallet that receives the tokensrequired
fundModetransfer, or approveAndCall with fundSelector (one uint256 argument)transfer
minClaimableclaimable fees that make a run worthwhile (wei)required
minIntervalseconds between runs3600
nativeReserveETH kept for operator fees0.001 ETH
maxSwapInPerRunper-run ETH cap, 0 = none0
nativeCapPerEpochETH outflow allowance per epochrequired
rewardCapPerEpochreward-token outflow allowanceuncapped
epochLengthfixed window in seconds604800 (7 days)
maxFeePerRunmax 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 automation updatePolicy then createTask.
  • 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.

FailureWhat happens
not enough claimableeligibility says so; the worker re-checks on the escrow's Credited event
sweep needs pons's operatorstep 0/1 fails, the run continues
price movedthe swap reverts on minOut; the run fails with slippage and retries with a fresh quote
epoch allowance used upAllowanceExceeded in simulation; next check at the epoch end
paused / operator revokednothing 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.

GET/v1/automation-stats

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"

GET/v1/automations/:address

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"

GET/v1/operators

curl -s "http://127.0.0.1:8787/v1/operators"

GET/v1/workflows

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.

GET/v1/simulations/:id

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

POST/v1/policy/encode

actionargssigner
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.

terminal

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

RouteWhatReads
/appthe 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-wideoverview, timeline, runs, stream
/app/terminalthe terminal full page; a slide-up drawer on every /app page, toggled with Ctrl+`every read route; writes return calldata for the wallet
/explorecharts (runs per day, ETH moved, success rate, operators, gas), the leaderboard and recent runs across the protocoltimeseries, leaderboard, runs, stream
/app/a/:addressone automation: policy, allowances, balances, tasks, runsautomations/:address, runs
/app/a/:address/runs/:idone run: steps, transactions, deltas, quote vs realizedruns/:id
/app/newcreate an automation: the fees -> rewards builder or an empty oneworkflows, preview, prepare-create

Overview

GET/v1/dashboard/overview

curl -s "http://127.0.0.1:8787/v1/dashboard/overview?owner=0x3d8a0670d278c125966f884dc66bea34944a4220"
json
{ "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" } ] }
FieldRule
syncedBlockthe worker's sync:<chainId> cursor, else the highest automation syncedBlock; null before the first sync
headBlocketh_blockNumber (10 s cache, 1.5 s budget), else the indexer's chain_state (56 only), else null
tiles.runs*24hruns started in the last 24 h (runs24h counts every status)
tiles.successRate30dsucceeded / (succeeded + failed) over 30 days, 4 decimals; null with no finished runs
tiles.ethMoved24hsum of native ETH outflow deltas of runs started in the last 24 h (wei)
tiles.depositTotalsum of the automations' native balances (wei)
tiles.runwayDaysdepositTotal / (feesPaid over the last 7 days / 7), 2 decimals; null when no fee was paid in 7 days
tiles.operatorsActiveactive registry operators (protocol-wide even with owner)
automationsmost 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)
operatorsregistry 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.

kindseverityWhen
fee_budget_lowwarning; critical below 1xactive, 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_parkedwarningfailed_attempts >= WORKER_MAX_ATTEMPTS and retry_after in the future (taskId set)
policy_queuedinfoa queued (timelocked) policy update; the message carries the eta
operator_stalledcriticala worker heartbeat older than 120 s
run_failedwarninga failed run started in the last 24 h (newest 20); href = /app/a/<automation>/runs/run_<n>
worker_laggingwarningheadBlock - syncedBlock > 600

Timeline

GET/v1/dashboard/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"
json
{ "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

GET/v1/metrics/timeseries

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"
metricunitValue per bucket (by run `started_at`, UTC)
runs (default)countruns started
ethMovedweinative ETH outflow deltas
successRateratiosucceeded / (succeeded + failed), up to 4 decimals, "0" when none finished
gasgassum of gasUsed
feesPaidweisum of operator fees
automationscountcumulative automations created before the end of the bucket
operatorscountcumulative registry operators active at the end of the bucket; protocol-wide, owner is ignored

GET/v1/leaderboard

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

GET/v1/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"
json
{ "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.

sse
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 = an operators[] row of the overview; log = one worker log line.
  • worker and heartbeat ignore owner; log lines without an automation only reach streams without owner.
  • At most 200 concurrent streams per API process; above that 503 unavailable with retry-after: 10. A client that stops reading is disconnected after 1 MB buffered.
  • The site's live views degrade to polling /v1/runs and /v1/worker/logs every 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.

shell
npx conductor-operator init
npx conductor-operator start
  • init makes a new encrypted key on the machine, prints its address, and registers it once it holds the bond and some gas.
  • start runs the operator. Without a bond it stays in dry-run: it simulates and signs nothing.
  • Registering locks a 0.01 ETH bond 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.

FieldDefault for new automationsMeaning
gasOverhead90000gas the contract cannot see (intrinsic, calldata, payout), added to the measured gas
premiumBps2000margin on top of gas, in basis points
flatFee0.000005 ETHa flat amount per paid execute
maxFeePerExecute0.002 ETHthe most one execute can pay
feeCapPerEpochmax(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

GET/v1/operators

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.

response
{ "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" } } ] }

GET/v1/operators/:address

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.

POST/v1/operators/heartbeat

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.

leaf
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

GET/v1/rewards/program

Config values (epoch length, holdMin, minRuns, capBps, claim window, linked-wallet limit) and the distributor address.

GET/v1/rewards/epochs

Every epoch: epoch, start, end, pool, total, root, uri, status (open, scoring, posted, expired).

GET/v1/rewards/epochs/:n

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.

GET/v1/rewards/creations/:id

Qualifying users this round for a recipe slug or an automation address: the "Used by N people this round" line.

POST/v1/rewards/link-wallet

{ 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.

json
{ "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"
json
{ "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

FieldRequiredNotes
addressyesMust have code, else 422 unsupported.
kindnoerc20 | erc721 | abi. Omitted => detected by the indexer.
abifor abiJSON ABI array, max 256 KB. Its events are decoded. For erc20/erc721 it adds extra events.
startBlocknoOmitted => 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.
labelnoMax 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

GET/v1/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"

GET/v1/contracts/:address

curl -s "http://127.0.0.1:8787/v1/contracts/0xa24326d9bcbd110c918dcf386472441b9347cbf6"
json
{ "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"
json
{ "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"
json
{ "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"
json
{ "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.

json
{ "contract": "0xa243...cbf6", "kind": "erc20", "symbol": "GITFUND", "decimals": 18, "block": 94596911,
  "holderCount": 412, "total": "1000000000000000000000000000",
  "rows": [ { "holder": "0x5ea3...b5ef", "balance": "120000000000000000000000000", "formatted": "120000000" } ] }
csv
holder,balance,formatted
0x5ea3...b5ef,120000000000000000000000000,120000000

erc721 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.

MethodPathBody / queryResponse
POST/v1/webhooks{ contract, url, eventName?, address?, description?, fromBlock? }201 with secret, shown once
GET/v1/webhookscontract?, limit, cursorPage<Webhook>
GET/v1/webhooks/:idWebhook
PATCH/v1/webhooks/:id{ active?, url?, description? }Webhook; active: true resets failures
DELETE/v1/webhooks/:id204
GET/v1/webhooks/:id/deliverieslimit, cursorPage<WebhookDelivery>
POST/v1/webhooks/:id/test200 WebhookDelivery (sends a ping)

Create

key
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.

key
curl -s "http://127.0.0.1:8787/v1/webhooks?contract=0xa24326d9bcbd110c918dcf386472441b9347cbf6&limit=20" \
  -H "x-api-key: $CONDUCTOR_API_KEY"
key
curl -s "http://127.0.0.1:8787/v1/webhooks/wh_3f9a0c1d2e4b5a6978877665" \
  -H "x-api-key: $CONDUCTOR_API_KEY"
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}'
key
curl -s "http://127.0.0.1:8787/v1/webhooks/wh_3f9a0c1d2e4b5a6978877665/deliveries?limit=20" \
  -H "x-api-key: $CONDUCTOR_API_KEY"
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. fromBlock defaults 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.blockNumber and event.logIndex to order.
http
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
json
{
  "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.

node
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 Gone disables 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.

ScopeCreated atReceives
automation/v1/automations/:address/webhooksEvents of that one automation, while the signer still owns it.
owner/v1/owners/:address/webhooksEvents of every automation the address owns, plus operator.stalled for operators that ran them.
MethodPathBody / queryResponse
POSTP{ url, events?, depositLowRuns?, description? }201 with secret, shown once
GETPlimit (<= 200), cursorPage<Webhook>
GETP/:idWebhook
PATCHP/:id{ url?, events?, depositLowRuns?, description?, active? }Webhook
DELETEP/:id204
POSTP/:id/test200 WebhookDelivery (a ping; once per 10 s)
GETP/:id/deliverieslimit (default 20), cursorPage<WebhookDelivery>
POST/v1/owners/:address/sessions{}201 { token, owner, chainId, expiresAt }
  • P is /v1/automations/:address/webhooks or /v1/owners/:address/webhooks.
  • events: any of the 12 types below; empty or omitted = every type. An unknown type is 400 with details.valid.
  • depositLowRuns: the automation.deposit_low threshold in runs the deposit still covers, 1 to 10000, default 24.
  • At most 10 alert webhooks per owner (422 limit_reached). An :id from another path is 404.
  • Events that happened before a webhook was created are never sent.

Event catalogue

TypeFires whenSeverity
run.succeededA run finishes successfully.info
run.failedA run fails.warning
run.partialA multi-step run advances with steps left (once per step).info
task.parkedA task reaches the failure limit and is parked until a retry time.warning
automation.fee_budget_lowFee budget left is under 3 average runs (critical under 1).warning / critical
automation.deposit_lowThe ETH deposit covers fewer runs than depositLowRuns.warning
operator.stalledAn operator's heartbeat is 120 s to 24 h old.critical
policy.queuedA timelocked policy update is queued.info
policy.executedA queued policy update is executed.info
automation.pausedThe automation is paused.warning
automation.resumedThe automation is unpaused.info
proposal.createdA connected agent proposed a change. Fixed summary and a review link; never the agent's note.info
finding.openedConductor'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 maxFeePerRun of the active tasks.
  • skipped, aborted and stuck runs do not alert. Nothing is retracted after a reorg.
json
{
  "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.

text
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: 1792800000
  • action: create-webhook, update-webhook, delete-webhook, read-webhooks, test-webhook or open-session.
  • webhook is the :id or -. url and events come 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 unauthorized carries details.reason: missing, expired, bad_signature, replayed or session_invalid. A valid signature from another wallet is 403 forbidden.
ts
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 });
shell
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 0x5b1f00000000000000000000000000000000a001

Relay 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.

ts
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.
  • applied means 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, warning or info. 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". source is template (default) or model (reworded, rejected if it adds a number, address or action).
  • Mute per automation with the owner-auth action set-manager (url line keccak256("mute" | "unmute")). Dismissing a finding mutes it for 7 days. If the manager is off, automations keep running.
MethodPathAuthDoes
GET/v1/automations/:address/managerpublic{ muted, openFindings, bySeverity }
GET/v1/automations/:address/findingspublicfindings, ?status=open|resolved|dismissed
POST/v1/automations/:address/managerowner signature (set-manager){ muted }
POST/v1/findings/:id/dismissowner sessiondismiss; not raised again for 7 days
GET/v1/runs/:id/explanationpublic{ 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.

ScopeAllows
readReads this automation's runs, policy, tasks and balances. It can't suggest anything.
propose:limitedReads, and can suggest: pause, turn a task off, revoke an operator, lower caps, cancel a queued rule change. You sign each one.
proposeEverything 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.

MethodPathAuthDoes
POST/v1/owners/:address/agent-keysowner signature (grant-agent-key)grant; { key, token }, the token in this response only
GET/v1/owners/:address/agent-keyssignature or sessionlist keys, never tokens
DELETE/v1/owners/:address/agent-keys/:idsignature or sessionrevoke
GET/v1/agent-keys/meagent keythe 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.

ToolDoes
whoamiThe key's name, owner, automations, scope and expiry.
list_automationsAutomations; with a key, the ones it was granted.
get_automationOne automation: policy, tasks, balances, operator, recent state.
list_runsRecent runs with facts Conductor reads from chain.
simulate_taskSimulate a task at the chain head. Results only, no calldata. Needs a key.
create_proposalSuggest one change. Returns its id, status, review link and expiry. Needs a propose scope.
get_proposalA suggestion's status and the change Conductor rendered from it.

Proposal lifecycle

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)
StatusMeaning
openchecked and simulated, waiting for the owner
queuedthe owner signed the queue step; waiting for the eta and the second signature
applieda verified receipt matches the prepared transactions
partially_applieda multi-transaction proposal landed only in part; the site offers the rest
dismissedthe owner said no; the same proposal from the same key is muted for 7 days
withdrawnthe creating key withdrew it
expirednot signed in time: 72 hours (pause: 24 hours)
invalidthe automation changed and the proposal no longer passes its checks
MethodPathAuthDoes
POST/v1/automations/:address/proposalsagent key (propose*)create; accepts idempotency-key
GET/v1/automations/:address/proposalsowner session, or the key (its own)list, ?status=
GET/v1/proposals/:idowner, or the creating keyone proposal with its diff and checks
POST/v1/proposals/:id/prepareowner session (prepare-proposal)revalidate; the owner's TxRequest[] and calldata hashes
POST/v1/proposals/:id/appliedownerreport a tx hash; the server verifies the receipt
POST/v1/proposals/:id/dismissowner session (dismiss-proposal)dismiss (7-day mute)
POST/v1/proposals/:id/withdrawthe creating keywithdraw

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

shell
claude mcp add --transport http conductor https://useconductor.xyz/mcp --header "Authorization: Bearer cagt_<your key>"

Claude Desktop

claude_desktop_config.json
{
  "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.

ts
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).

shell
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 ...

Keyboard shortcuts

  • Control roomga
  • New automationgn
  • Inboxgi
  • Terminalgt
  • Runs, protocol-widegu
  • Explorege
  • Hooksgh
  • Studiogs
  • Recipesgr
  • Operatorsgk
  • Agentsgm
  • Rewardsgw
  • Datagd
  • Docsgo
  • Homegg
  • Toggle the terminal drawer (app pages)ctrl+`
  • Show this list?
  • Close a menu or this listesc