crouter
SDK

Nodes

Create agent runs, wait for outcomes, and parse structured results.

client.nodes

Shipped, except where a row says otherwise.

A node is one agent run on the crouter canvas. It is asynchronous by construction: create returns as soon as the daemon has spawned it, and the run keeps working after your call returns.

There is one name for it — client.nodes. There is no client.responses alias. A node has a canvas identity, a kind, a working directory, a profile, a deadline, reports, subscriptions, children, and a lifecycle; naming it responses would promise previous_response_id, output_text, tools, and store semantics that do not exist here.

Methods

| Method | Route | Phase | Notes | |---|---|---|---| | nodes.create(params) | POST /v1/nodes | 1 | Returns immediately with the node; it is already running. | | nodes.retrieve(id) | GET /v1/nodes/{id} | 1 | | | nodes.list(query?) | GET /v1/nodes | 1 | Returns an array, as the daemon does. | | nodes.outcome(id, { wait }) | GET /v1/nodes/{id}/outcome?wait= | 1 | One long poll, wait 0–25 s. Returns the wire envelope with state: 'pending' | 'settled'. | | nodes.waitForOutcome(id, opts?) | repeats the above | 1 | Loops until settled. Honours signal; no implicit time bound. | | nodes.createAndWait(params, opts?) | create + wait | 1 | | | nodes.parse(params, opts?) | create + wait + typed result | 1 | See below. | | nodes.message(id, body) | POST /v1/nodes/{id}/messages | 1 | Send a follow-up to a running or dormant node. | | nodes.interrupt(id) | POST /v1/nodes/{id}/interrupt | 1 | Stop the current turn, keep the node. | | nodes.cancel(id, body?) | POST /v1/nodes/{id}/close | 1 | Tears the node and its exclusive subtree down. cancel is the method name; close is the route. | | nodes.update(id, patch) | PATCH /v1/nodes/{id}/config | Shipped | | | nodes.fork(id) | POST /v1/nodes/{id}/fork | Shipped | | | nodes.revive(id, body?) | POST /v1/nodes/{id}/revive | Shipped | | | nodes.promote(id, body?) / nodes.demote(id) | matching routes | Shipped | Move a node between base and orchestrator mode. | | nodes.recycle(id) | POST /v1/nodes/{id}/recycle | Shipped | | | nodes.yield(id, body?) | POST /v1/nodes/{id}/yield | Shipped | | | nodes.wait(id, body) | POST /v1/nodes/{id}/wait | Shipped | | | nodes.relaunchRoot(id) | matching route | Shipped | | | nodes.reviveAll() | POST /v1/nodes/revive-all | Shipped | | | nodes.stream(params, options?) | create + GET /…/events | Shipped | Streaming. | | nodes.events(id, options?) | GET /v1/nodes/{id}/events | Shipped | Streaming. |

Action methods keep the product's literal name (fork, revive, promote, yield) rather than being renamed into a generic verb.

Every method in the table accepts RequestOptions as its final argument: { headers?, signal?, timeout?, maxRetries? }. nodes.stream(params, options?) uses those options for create and stream opening, except that its stream has no wall-clock timeout; nodes.events(id, options?) takes NodeEventsOptions, which adds after and excludes timeout.

Create parameters

Wire fields are snake_case. Every NodeCreateParams property is optional; parse() additionally requires output_schema. NodeCreateParams is the daemon's CreateNodeRequest, except output_schema also accepts an object. When neither parent nor root is supplied, the SDK sends root: true; otherwise fields pass through without camelizing.

| Field | Type | Meaning | |---|---|---| | prompt | string | The run's entire brief. | | kind | string | Persona. Omit for the profile's default. | | model | string | Durable model override. | | profile | string | The store, memory, and purview the run uses. An application passes its own profile here. | | cwd | string | Where the request came from. | | pin_cwd | string | Pin the node to this directory regardless of cwd. | | situational_context | string | Ambient context kept out of the visible prompt. | | deadline | string (1h30m) | Wall clock from spawn. Expiry cancels the node and records deadline_exceeded. | | output_schema | string \| JsonSchema \| { toJSONSchema(): JsonSchema } | Widened from the wire's JSON string; the SDK serializes. A zod v4 object satisfies the third form. | | root | boolean | No parent, no subscription. An application's run is a root. | | root_lifecycle | 'terminal' \| 'resident' | terminal for a bounded run; resident for one a person will open and keep. | | mode | 'base' \| 'orchestrator' | Whether the node works hands-on or fans out to children. | | name | string | Display label. | | description | string | Display description. | | parent | node id | Graph placement. An external caller leaves this unset. | | creator | node id | Graph placement. An external caller leaves this unset. | | scopes | string[] | Per-run allow-list. Omit it to inherit every scope, or under a scoped token to receive that token's ceiling; a list outside the ceiling answers 403 scope_denied with the offending scopes in details.scopes. In beta, ask, act, schedule, memory:read, and memory:write are enforced; llm, files:<dir>, net, provider groups, and peers are recorded because their performers are not available. See scoped tokens. | | worktree | string \| boolean | Create a managed git worktree for the run. | | fork_from | string | Start from an existing conversation. | | no_kickoff | boolean | Create the node without sending the first message. | | node_id | string | Spawn at an exact id. A collision answers 409 node_id_exists. | | prefer_warm | boolean | Serve from the warm pool when the launch tuple matches. | | outcome_delivery | { action, payload? } | Arm outcome delivery at birth. |

node_id is how you make a create safely retryable. Retry with the same id and a duplicate fails loudly with 409 node_id_exists instead of quietly spawning a second agent — which is why the client never retries a POST for you. See Errors.

Outcomes

waitForOutcome, createAndWait, and parse return the settled NodeOutcome — the same union the API defines, not a translation of it.

| Wire | Narrow on | Carries | |---|---|---| | kind: 'result' | outcome.kind === 'result' | structured_result, final_report_path, and on parse a typed output_parsed | | kind: 'failure', reason: 'declined' | outcome.reason === 'declined' | declined: { reason, code, retryable } \| null — the agent honestly refused the schema | | kind: 'failure', any other reason | anything else | detail: NodeOutcomeDetailV1 \| nulldeadline_exceeded, a provider fault, a wedge |

Agent-side outcomes are returned, never thrown. A decline carries a typed reason, a code the agent chose, and a retryable flag; routing that through an exception would discard the payload and make the happy path lie about what happened. Only transport faults, daemon errors, and your own abort throw.

const outcome = await client.nodes.waitForOutcome(node.node_id, { signal });

switch (true) {
  case outcome.kind === 'result':
    console.log(outcome.structured_result, outcome.final_report_path);
    break;
  case outcome.reason === 'declined':
    console.warn(outcome.declined?.reason, outcome.declined?.retryable);
    break;
  default:
    console.error(outcome.reason, outcome.detail);
}

outcome() versus waitForOutcome()

nodes.outcome(id, { wait }) is one long poll: the daemon holds the request open for up to wait seconds (0–25) and answers { node_id, state: 'pending' | 'settled', outcome, node_status, deadline_at }. outcome is null while state is 'pending'. Use it when your own loop owns the timing — a job runner that wants to do other work between polls, or a UI that shows a heartbeat.

nodes.waitForOutcome(id, opts?) repeats that poll until the node settles. It applies no implicit time bound: an agent that runs for an hour is polled for an hour. Bound it from either side — pass a deadline to create so the daemon cancels the run, or pass a signal so your client stops waiting.

for (;;) {
  const poll = await client.nodes.outcome(node.node_id, { wait: 25 });
  if (poll.state === 'settled' && poll.outcome !== null) {
    console.log(poll.outcome);
    break;
  }
  // update your UI or job heartbeat here
}

parse() and structured output

import { z } from 'zod';

const run = await client.nodes.parse({
  prompt: 'Summarize the failing tests in this repo.',
  cwd: '/path/to/repo',
  output_schema: z.object({ failures: z.array(z.string()), root_cause: z.string() }),
});

if (run.kind === 'result') console.log(run.output_parsed.root_cause);
else if (run.reason === 'declined') console.warn(run.declined?.reason);

ParsedOutcome<T> is NodeOutcome & { output_parsed: T | null }, discriminated so output_parsed is non-null exactly when kind === 'result'.

output_parsed is structured_result typed to the schema you passed. A Zod schema uses its inferred output type; a Standard Schema uses ~standard.types.output. A JSON-Schema literal and any other object with toJSONSchema() are accepted and serialized, but their output_parsed type is unknown. The SDK does not re-validate the result — the daemon already enforces the schema when the agent submits it, so a second validation pass would only produce a second set of error messages for the same rejection. There is no helper equivalent to OpenAI's zodTextFormat.

A candidate from Basis's real structured SDK result:

{
  "tmp": "k1",
  "type": "claim",
  "slug": "config-memories-in-project",
  "text": "All applet configuration memories belong in project memories.",
  "quote": "All configuration memories should be in the project memories. We know that.",
  "speaker": "Me",
  "confidence": "green"
}

Sending a follow-up

nodes.message(id, body) appends to a node's inbox. A dormant node wakes to read it; a running node picks it up at its next turn boundary.

await client.nodes.message(node.node_id, { body: 'Also check the integration lane.' });

Stopping a run

| Call | Effect | |---|---| | nodes.interrupt(id) | Stops the current turn. The node stays on the canvas and can be messaged or revived. | | nodes.cancel(id, body?) | Tears the node down along with the subtree it exclusively owns. Terminal. |

Aborting a signal you passed to waitForOutcome stops your client waiting. It does not stop the node. Call cancel for that.

Nested resources

Nested routes become nested properties.

| Property | Methods | Phase | |---|---|---| | nodes.reports | list | Shipped | | nodes.jobs | list, cancel | Shipped | | nodes.worktree | close, abandon | Shipped | | nodes.result | submit | Shipped — only an agent inside a run calls this |

nodes.reports.list(id) returns the agent's pushed progress reports, newest first. Use it when reports are the progress view your application needs; use streaming for live output and tool-call events.

const reports = await client.nodes.reports.list(node.node_id, { limit: 10 });
for (const report of reports) console.log(report.tier, report.body);

Pagination

List routes return what the daemon returns. nodes.list, nodes.reports.list, and nodes.jobs.list return plain arrays — there is no page object, no hasNextPage(), and no after cursor on them, because the daemon has no paging substrate behind those routes and an envelope there would promise a continuation that can never happen.

The one place the page envelope exists is the phase-3 memory routes. See Memory.

Identifier validation

Methods that take a node id validate it before the request: core node methods, lifecycle methods, nodes.reports, nodes.jobs, nodes.worktree, and nodes.result. An invalid id throws TypeError locally and sends no request. nodes.outcome(id, { wait }) also throws RangeError locally when wait is not an integer from 0 through 25.