@north-light/crouter 0.3.333 → 0.3.335

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,12 +11,12 @@ when-and-why-to-read: When you need Plugin overview, this knowledge should be
11
11
 
12
12
  Start with the `node_modules/@north-light/crouter-plugin/README.md` for a complete plugin that a developer can paste into an empty file. Then read the page that matches the work at hand:
13
13
 
14
- - [Getting started](getting-started.md) — the deployment and install sequence.
15
- - [Commands](commands.md) — command trees and descriptions that let an agent choose a command.
16
- - [Parameters and handler input](parameters.md) — every `param.*` builder and the inferred handler input type.
17
- - [Output](output.md) — every `field.*` builder and handler result validation.
18
- - [Errors and streaming](errors.md) — `LeafError`, HTTP errors, and NDJSON streaming leaves.
19
- - [Deployment](deploying.md) — Fetch frameworks, mount paths, proxies, authentication, and archive compression.
20
- - [Bundles and memory docs](bundles-and-memory.md) — generating an archive and shipping memory docs.
14
+ - [[crouter-plugin/getting-started]] — the deployment and install sequence.
15
+ - [[crouter-plugin/commands]] — command trees and descriptions that let an agent choose a command.
16
+ - [[crouter-plugin/parameters]] — every `param.*` builder and the inferred handler input type.
17
+ - [[crouter-plugin/output]] — every `field.*` builder and handler result validation.
18
+ - [[crouter-plugin/errors]] — `LeafError`, HTTP errors, and NDJSON streaming leaves.
19
+ - [[crouter-plugin/deploying]] — Fetch frameworks, mount paths, proxies, authentication, and archive compression.
20
+ - [[crouter-plugin/bundles-and-memory]] — generating an archive and shipping memory docs.
21
21
 
22
22
  The package exports only `LeafError`, `ManifestInvalidError`, `PluginDefinitionError`, `buildBundle`, `buildCommandManifest`, `createFetchHandler`, `defineBranch`, `defineLeaf`, `definePlugin`, `defineStreamingLeaf`, `field`, `kebab`, and `param`. Those are the complete authoring surface.
@@ -13,6 +13,6 @@ when-and-why-to-read: When you need Commands, this knowledge should be read
13
13
 
14
14
  Use `defineBranch` to group commands and give it `children`. Use `defineLeaf` for a request that returns one object. A leaf requires `params`, `output`, `effects`, and `handler`; `params` may be omitted when the command has no input. `effects` is a non-empty list shown to the agent. Say whether a command mutates an application, creates billable resources, or is read-only.
15
15
 
16
- `defineStreamingLeaf` has the same declaration shape, but its handler returns `AsyncIterable<object>`. It marks the generated REST mapping as streaming and is covered in [Errors and streaming](errors.md).
16
+ `defineStreamingLeaf` has the same declaration shape, but its handler returns `AsyncIterable<object>`. It marks the generated REST mapping as streaming and is covered in [[crouter-plugin/errors]].
17
17
 
18
18
  All command and parameter keys are derived into the manifest. Do not add a route path, HTTP method, REST mapping, or separate manifest to a command definition. The package always generates `POST` with all parameters in the JSON body.
@@ -27,4 +27,4 @@ Pass `token` to require `Authorization: Bearer <token>` on archive and command r
27
27
 
28
28
  The install response is an uncompressed tar with `Content-Type: application/x-tar`. Do not configure a proxy, CDN, or framework middleware to gzip or otherwise compress it. Crtr rejects compressed archive bytes. The handler sends an ETag and supports conditional `If-None-Match` requests automatically.
29
29
 
30
- A non-streaming successful command response is a bare JSON result object. Error responses are JSON error envelopes on non-2xx statuses. See [Errors and streaming](errors.md) for the exact error shape.
30
+ A non-streaming successful command response is a bare JSON result object. Error responses are JSON error envelopes on non-2xx statuses. See [[crouter-plugin/errors]] for the exact error shape.
@@ -22,4 +22,4 @@ The endpoint receives `GET` to provide the install archive and `POST` to run a c
22
22
 
23
23
  Run `crtr pkg plugin show acme` after installation to inspect the installed command tree, and `crtr sys doctor` to check the installed package. A plugin name collision with crtr or another installed plugin is determined during installation and cannot be predicted from an application repository alone.
24
24
 
25
- Continue with [deployment](deploying.md) before mounting the handler below the origin root or behind a proxy.
25
+ Continue with [[crouter-plugin/deploying]] before mounting the handler below the origin root or behind a proxy.
@@ -0,0 +1,19 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When composing crouter SDK namespaces into an application,
4
+ read this because it helps you choose a complete runnable starting point.
5
+ ---
6
+
7
+ # SDK recipes
8
+
9
+ These recipes turn the SDK namespaces into complete applications: a bounded extraction, an assistant that waits for events, a research pipeline, a human approval step, and application-owned memory.
10
+
11
+ | Recipe | Use it when | It ends with |
12
+ |---|---|---|
13
+ | [[crouter-sdk/guides/typed-extraction]] | You need one structured answer and no follow-up. | A typed object or an explicit failure. |
14
+ | [[crouter-sdk/guides/event-driven-assistant]] | Events arrive over time from a webhook, queue, or watcher. | A resident node that wakes for each event. |
15
+ | [[crouter-sdk/guides/fan-out-pipeline]] | One task needs independent research before a synthesis. | An orchestrator's final report. |
16
+ | [[crouter-sdk/guides/human-approval]] | A person must decide before work continues. | A node resumed by an inbox answer. |
17
+ | [[crouter-sdk/guides/app-memory]] | Several runs need the same durable application knowledge. | A profile-owned document read by a scoped run. |
18
+
19
+ Start with typed extraction for a request/response job. Use a resident node when the same assistant should react again later. Use an orchestrator only when child work can proceed independently; otherwise keep the composition in your application.
@@ -0,0 +1,73 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When an application's agents need shared durable
4
+ knowledge, read this because a profile store gives runs one owned memory
5
+ location and scopes can allow reading without writing.
6
+ ---
7
+
8
+ # Application memory
9
+
10
+ Use this recipe when several runs need the same application guidance. Run `npx tsx examples/guides/app-memory.ts /path/to/repo`; it ensures a profile, creates a profile-owned knowledge document if it does not already exist, then starts a run allowed to read but not write memory.
11
+
12
+ ```ts
13
+ import Crouter, { NotFoundError } from '@north-light/crouter-sdk';
14
+ import { resolve } from 'node:path';
15
+ import { z } from 'zod';
16
+
17
+ const client = new Crouter();
18
+ const cwd = resolve(process.argv[2] ?? process.cwd());
19
+ const profileName = process.env.APP_PROFILE ?? 'release-notes-app';
20
+
21
+ const profile = await client.profiles.ensure(profileName, {
22
+ projects: [{ path: cwd, memory: 'content' }],
23
+ });
24
+
25
+ try {
26
+ await client.memory.retrieve('release/voice', { profile: profile.id, store: 'profile' });
27
+ } catch (error) {
28
+ if (!(error instanceof NotFoundError)) throw error;
29
+ await client.memory.create({
30
+ name: 'release/voice',
31
+ kind: 'knowledge',
32
+ when_and_why_to_read: 'When writing release notes, read this because it defines the product voice and terms customers recognize.',
33
+ body: 'Use short factual sentences. Name the user-visible result before implementation details.',
34
+ frontmatter: { surfaces: ['{"on":"boot","at":"preview"}'] },
35
+ profile: profile.id,
36
+ store: 'profile',
37
+ });
38
+ }
39
+
40
+ const result = await client.nodes.parse({
41
+ cwd,
42
+ model: process.env.GUIDE_MODEL,
43
+ profile: profile.id,
44
+ root: true,
45
+ root_lifecycle: 'terminal',
46
+ deadline: '5m',
47
+ scopes: ['memory:read'],
48
+ prompt: 'Write one short release note announcing that customers can download invoices as CSV from account settings. Do not write or update memory.',
49
+ output_schema: z.object({ release_note: z.string() }),
50
+ });
51
+
52
+ if (result.kind === 'result') {
53
+ console.log(result.output_parsed.release_note);
54
+ } else if (result.reason === 'declined') {
55
+ console.error(`agent declined: ${result.declined?.reason ?? 'no reason supplied'}`);
56
+ process.exitCode = 2;
57
+ } else {
58
+ console.error(`agent failed: ${result.reason}`, result.detail);
59
+ process.exitCode = 1;
60
+ }
61
+ ```
62
+
63
+ A profile is the application's durable identity: it names the memory store and the projects whose context the profile can reach. The routing line is part of the document, not decoration. State both the moment the agent should read it and the reason it matters; a document with a vague routing line will not surface when the agent needs it.
64
+
65
+ The `boot`/`preview` surface exposes the document's routing line when this profile's agent starts. The task asks for a release note without naming `release/voice`; in the local run the agent read that document before producing the note. Without a surface entry, a memory document appears only in its directory listing and its routing line cannot select it at boot. The run passes `scopes: ['memory:read']`. That is an allow-list: node-targeted memory reads are allowed and writes are refused because `memory:write` is absent. The application itself is not narrowed by those node scopes, so keep application credentials and its memory mutation policy separate from the agent's capabilities.
66
+
67
+ Observed against the local daemon with `APP_PROFILE=release-notes-app-guide-boot-preview GUIDE_MODEL=openai-codex/gpt-6-sol:high`:
68
+
69
+ ```text
70
+ You can now download your invoices as a CSV from account settings.
71
+ ```
72
+
73
+ Use profile memory for knowledge shared across the application's related projects. Put facts that belong to one repository in project memory instead, and temporary run notes in node memory. See [[crouter-concepts/memory]] for the ownership tiers and [[crouter-concepts/profiles-kinds-and-modes]] for what a profile changes.
@@ -0,0 +1,91 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When an assistant must react to later webhook, queue, or
4
+ file-watcher events, read this because a resident node sleeps until
5
+ nodes.message delivers the next event.
6
+ ---
7
+
8
+ # Event-driven assistant
9
+
10
+ Use this recipe for an assistant that reacts to an event source instead of ending after one request. Run `npx tsx examples/guides/event-driven-assistant.ts /path/to/repo`, then type events into standard input to stand in for a webhook handler or file watcher.
11
+
12
+ ```ts
13
+ import Crouter from '@north-light/crouter-sdk';
14
+ import { resolve } from 'node:path';
15
+ import { createInterface } from 'node:readline';
16
+
17
+ const client = new Crouter();
18
+ const cwd = resolve(process.argv[2] ?? process.cwd());
19
+
20
+ const assistant = await client.nodes.create({
21
+ name: 'event assistant',
22
+ cwd,
23
+ model: process.env.GUIDE_MODEL,
24
+ root: true,
25
+ root_lifecycle: 'resident',
26
+ no_kickoff: true,
27
+ situational_context: `You are a standing assistant. Each inbox message is an event from an external source. For each event, briefly state what happened and one useful next action, then push that response as an update report with crtr push update. Do not push final. End the turn and go dormant after reporting. Do not poll or schedule a timer: another event will arrive as a message.`,
28
+ });
29
+
30
+ console.log(`assistant node: ${assistant.node_id}`);
31
+ console.log('Type an event and press Enter. Press Ctrl-C to stop the assistant.');
32
+
33
+ const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
34
+ let stopping: Promise<void> | undefined;
35
+
36
+ function stop(): Promise<void> {
37
+ if (stopping !== undefined) return stopping;
38
+ input.close();
39
+ stopping = client.nodes.cancel(assistant.node_id).then(() => undefined);
40
+ return stopping;
41
+ }
42
+
43
+ process.once('SIGINT', () => {
44
+ void stop().catch((error: unknown) => {
45
+ console.error(error);
46
+ process.exitCode = 1;
47
+ });
48
+ });
49
+
50
+ for await (const line of input) {
51
+ if (stopping !== undefined) break;
52
+ if (line.trim() === '') continue;
53
+ try {
54
+ const previous = (await client.nodes.reports.list(assistant.node_id, { limit: 1 }))[0]?.path;
55
+ if (stopping !== undefined) break;
56
+ await client.nodes.message(assistant.node_id, { body: line });
57
+ while (stopping === undefined) {
58
+ const report = (await client.nodes.reports.list(assistant.node_id, { limit: 1 }))[0];
59
+ if (stopping !== undefined) break;
60
+ if (report !== undefined && report.path !== previous) {
61
+ console.log(report.body);
62
+ break;
63
+ }
64
+ const state = await client.nodes.retrieve(assistant.node_id);
65
+ if (stopping !== undefined) break;
66
+ if (state.fault?.retry.disposition === 'fatal' || state.status === 'dead' || state.status === 'canceled') {
67
+ throw new Error(`assistant stopped without reporting: ${state.fault?.message ?? state.status}`);
68
+ }
69
+ await new Promise((resolve) => setTimeout(resolve, 1000));
70
+ }
71
+ } catch (error) {
72
+ if (stopping === undefined) throw error;
73
+ }
74
+ }
75
+
76
+ await stop();
77
+ ```
78
+
79
+ The external source owns detection. When it receives an event, it calls `nodes.message(nodeId, { body })`. `no_kickoff` leaves the resident node waiting for the first event, while `situational_context` tells it how to handle every event. A dormant resident node wakes for the message; a running node reads it at its next turn boundary. Keep the returned node id with the application so each later event reaches the same context and memory. The agent pushes one update report per event, which the application reads through `nodes.reports.list` and prints. The application checks for a fatal node fault rather than waiting forever for a report that cannot arrive.
80
+
81
+ The agent does not poll or schedule a timer: the daemon wakes it on each message, and it goes dormant after reporting. This console program polls for the report associated with the event it just sent; an application with its own event loop can also consume the node's event stream. Ctrl-C calls `nodes.cancel()` only because this interactive example needs an explicit way to stop.
82
+
83
+ Observed against the local daemon with `GUIDE_MODEL=openai-codex/gpt-6-sol:high` after sending `Build completed with 2 failing checks: lint and unit tests.`:
84
+
85
+ ```text
86
+ assistant node: 3zl47w7d-mud5amcx-67978c9b
87
+ Type an event and press Enter. Press Ctrl-C to stop the assistant.
88
+ The build completed, but **lint and unit tests failed**. Inspect the two failing check logs first to identify the errors.
89
+ ```
90
+
91
+ Use a resident node only when future events belong to the same ongoing assistant. For bounded work, use [[crouter-sdk/guides/typed-extraction]]. See [[crouter-concepts/lifecycle-and-wakes]] for why waiting is free and [[crouter-concepts/why-a-daemon]] for why the process can outlive the terminal that started it.
@@ -0,0 +1,57 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When independent parts of one job need separate agent work
4
+ before one synthesis, read this because an orchestrator can spawn children,
5
+ wait for reports, and publish a final result while the SDK streams progress.
6
+ ---
7
+
8
+ # Fan-out pipeline
9
+
10
+ Use this recipe when one result needs independent investigation first. Run `npx tsx examples/guides/fan-out-pipeline.ts /path/to/repo`; it asks an orchestrator to inspect a package through two children, prints live text and pushed reports, then prints the final reports retained on the node.
11
+
12
+ ```ts
13
+ import Crouter from '@north-light/crouter-sdk';
14
+ import { resolve } from 'node:path';
15
+
16
+ const client = new Crouter();
17
+ const cwd = resolve(process.argv[2] ?? process.cwd());
18
+
19
+ const stream = client.nodes.stream({
20
+ name: 'research pipeline',
21
+ cwd,
22
+ root: true,
23
+ root_lifecycle: 'terminal',
24
+ mode: 'orchestrator',
25
+ deadline: '10m',
26
+ prompt: `Research the package in this directory. Spawn two focused children: one should inspect package.json and one should inspect the README. Wait for their reports, reconcile them, then push a final report with the package name, purpose, and one risk or unknown.`,
27
+ });
28
+
29
+ const node = await stream.node;
30
+ console.log(`orchestrator node: ${node.node_id}`);
31
+
32
+ for await (const event of stream) {
33
+ if (event.type === 'node.output_text.delta') process.stdout.write(event.delta);
34
+ if (event.type === 'node.report.pushed') console.log(`\nreport: ${event.report.body}`);
35
+ }
36
+
37
+ const outcome = await stream.finalOutcome();
38
+ const reports = await client.nodes.reports.list(node.node_id, { limit: 10 });
39
+ console.log(`\nfinal outcome: ${outcome.kind}`);
40
+ for (const report of reports) console.log(`[${report.tier}] ${report.body}`);
41
+
42
+ if (outcome.kind !== 'result') process.exitCode = 1;
43
+ ```
44
+
45
+ `nodes.stream()` creates the orchestrator and observes it. It does not run the orchestration in your process: the node owns its children, waits for their pushed reports, and produces the synthesis. The stream gives your application live output and reports; `nodes.reports.list()` gives it the durable report view after settlement.
46
+
47
+ Keep orchestration inside the canvas when children need the canvas's report delivery, durable waits, and a parent that can be inspected or resumed. Keep it in your application when tasks are simple independent calls and the application already owns their scheduling and aggregation. Do not use an orchestrator merely because a task is long; it earns the extra structure only when child work can run independently.
48
+
49
+ A local run created `j6amiqxs-mud4drnk-330da196`; after its two children reported, `waitForOutcome()` returned:
50
+
51
+ ```text
52
+ { "kind": "result", "reason": "finalized" }
53
+ ```
54
+
55
+ The same run's `nodes.stream()` observer ended during a dormant gap before it could print this outcome. A separate daemon fix is correcting that observer behavior; the example remains the intended streamed shape, but its streamed path has not yet been observed end to end.
56
+
57
+ See [[crouter-concepts/nodes-and-the-canvas]] for reports and durable identities, and [[crouter-concepts/profiles-kinds-and-modes]] for the base-versus-orchestrator decision.
@@ -0,0 +1,79 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When a person must approve or reject work before an agent
4
+ continues, read this because the SDK can create an inbox request whose answer
5
+ wakes the requesting node.
6
+ ---
7
+
8
+ # Human approval
9
+
10
+ Use this recipe when a decision belongs to a person, not a timeout or an agent guess. Run `npx tsx examples/guides/human-approval.ts /path/to/repo`; it prints an inbox ticket id. Answer the page in the crtr human inbox and the worker wakes with that answer before it reports its result.
11
+
12
+ ```ts
13
+ import Crouter from '@north-light/crouter-sdk';
14
+ import { resolve } from 'node:path';
15
+
16
+ const client = new Crouter();
17
+ const cwd = resolve(process.argv[2] ?? process.cwd());
18
+
19
+ const worker = await client.nodes.create({
20
+ name: 'approval worker',
21
+ cwd,
22
+ root: true,
23
+ root_lifecycle: 'terminal',
24
+ no_kickoff: true,
25
+ });
26
+
27
+ const request = await client.human.requests.create({
28
+ creator_cwd: cwd,
29
+ requester_node_id: worker.node_id,
30
+ delivery: { placement: 'panel', inbox: true, reply: true },
31
+ page: {
32
+ dialect: 'jsx',
33
+ source: `export default function Approval() {
34
+ return (
35
+ <Page title="Approve the release?" subtitle="The worker will continue with your choice.">
36
+ <UserQuestion
37
+ id="approval"
38
+ label="Release action"
39
+ body="Approve to continue the release, or reject to stop it."
40
+ mode="single"
41
+ options={[
42
+ { id: 'approve', label: 'Approve', recommended: true },
43
+ { id: 'reject', label: 'Reject' },
44
+ ]}
45
+ />
46
+ </Page>
47
+ );
48
+ }`,
49
+ },
50
+ });
51
+
52
+ const ticket = await client.human.requests.retrieve(request.request_id);
53
+ if (ticket.inbox_ticket_id === null) throw new Error('the approval request was not added to the inbox');
54
+
55
+ await client.nodes.message(worker.node_id, {
56
+ body: `A human approval request is open. Wait for its reply. When it arrives, state whether the release was approved or rejected, then push a final report.`,
57
+ });
58
+
59
+ console.log(`approval ticket: ${ticket.inbox_ticket_id}`);
60
+ console.log('Answer it in the crtr human inbox. The worker will wake with the response.');
61
+
62
+ const outcome = await client.nodes.waitForOutcome(worker.node_id);
63
+ console.log(`worker outcome: ${outcome.kind}`);
64
+ if (outcome.kind !== 'result') process.exitCode = 1;
65
+ ```
66
+
67
+ The application creates the page through `client.human.requests.create()` and names the worker as `requester_node_id`. `delivery.reply: true` makes the settled answer travel back to that node. The page's `UserQuestion` supplies a known response shape, so the person sees an explicit approve or reject decision instead of an unstructured prompt.
68
+
69
+ The worker is terminal because this is a bounded approval run: it waits after setup, wakes when the person answers, then publishes its final result. The pending human request keeps that wait durable. The application does not poll the node or invent a fallback deadline; the human reply is a canvas event. `client.human.inbox` can list, inspect, and answer inbox tickets when your application provides its own human interface.
70
+
71
+ Observed against the local daemon after an approve response:
72
+
73
+ ```text
74
+ approval ticket: a643479ca7a79758bf86f32d24bd8c9b22e16509cd60996c5e4fe0bc5f097d45
75
+ Answer it in the crtr human inbox. The worker will wake with the response.
76
+ worker outcome: result
77
+ ```
78
+
79
+ See [[crouter-concepts/lifecycle-and-wakes]] for the wake model and [[crouter-concepts/scopes-and-trust]] for why the daemon, rather than the application, owns delivery of the answer.
@@ -0,0 +1,53 @@
1
+ ---
2
+ kind: knowledge
3
+ when-and-why-to-read: When an application needs one bounded structured answer
4
+ with no follow-up, read this because nodes.parse returns a typed outcome union
5
+ instead of hiding agent failures.
6
+ ---
7
+
8
+ # Typed extraction
9
+
10
+ Use this recipe to extract a small fact set from files in one working directory. Run it with `npx tsx examples/guides/typed-extraction.ts /path/to/repo`; it prints a package name and version or exits with an explicit agent outcome.
11
+
12
+ ```ts
13
+ import Crouter from '@north-light/crouter-sdk';
14
+ import { resolve } from 'node:path';
15
+ import { z } from 'zod';
16
+
17
+ const client = new Crouter();
18
+ const cwd = resolve(process.argv[2] ?? process.cwd());
19
+
20
+ const extraction = await client.nodes.parse({
21
+ prompt: 'Read package.json in this directory. Return its name and version.',
22
+ cwd,
23
+ root: true,
24
+ root_lifecycle: 'terminal',
25
+ deadline: '5m',
26
+ output_schema: z.object({
27
+ name: z.string(),
28
+ version: z.string(),
29
+ }),
30
+ });
31
+
32
+ if (extraction.kind === 'result') {
33
+ console.log(`package ${extraction.output_parsed.name}@${extraction.output_parsed.version}`);
34
+ } else if (extraction.reason === 'declined') {
35
+ console.error(`agent declined: ${extraction.declined?.reason ?? 'no reason supplied'}`);
36
+ process.exitCode = 2;
37
+ } else {
38
+ console.error(`agent failed: ${extraction.reason}`, extraction.detail);
39
+ process.exitCode = 1;
40
+ }
41
+ ```
42
+
43
+ `nodes.parse()` creates a terminal root, waits for it, and returns the structured result typed from the Zod schema. The schema belongs at the boundary where your application consumes the answer, not in a second parser after the run.
44
+
45
+ Observed against the local daemon in this checkout:
46
+
47
+ ```text
48
+ package @north-light/crouter@0.3.332
49
+ ```
50
+
51
+ Handle every outcome arm. A result contains `output_parsed`; a decline says the agent could not honestly meet the schema; another failure carries its reason and detail. Transport and daemon failures still throw, so let those reach your normal application error handling.
52
+
53
+ This is the right shape for bounded work: one prompt, one answer, and no future message. If a user or external event must return to the same agent later, use a resident node instead. See [[crouter-concepts/nodes-and-the-canvas]] for why an SDK node has durable identity, and [[crouter-concepts/lifecycle-and-wakes]] for the terminal-versus-resident decision.
@@ -1,6 +1,6 @@
1
- var O=Object.defineProperty;var o=(n,e)=>O(n,"name",{value:e,configurable:!0});import{randomUUID as q}from"node:crypto";import{ApiError as E}from"../../../api/errors.js";import{BrokerClient as C}from"../../../core/broker-client/client.js";import{getNode as b}from"../../../core/canvas/canvas.js";import{isPidAlive as B}from"../../../core/canvas/pid.js";import{BROKER_VIEW_SOCKET_WAIT_MS as k}from"../../../core/runtime/view-socket.js";import{toNodeOutcomeDTO as M}from"../map.js";import{reportsForNode as L}from"./reports.js";const D=15e3,$=6e4,x=250,P=512,T=256*1024,j=1e3,G=32*1024*1024;class V{static{o(this,"NodeEventRing")}events=[];bytes=0;nextSequence=1;earliestAvailable=1;append(e){const t={...e,sequence_number:this.nextSequence++},s=Buffer.byteLength(JSON.stringify(t),"utf8");if(s>T)return this.events=[],this.bytes=0,this.earliestAvailable=this.nextSequence,t;for(this.events.push(t),this.bytes+=s;this.events.length>P||this.bytes>T;){const r=this.events.shift();if(r===void 0)break;this.bytes-=Buffer.byteLength(JSON.stringify(r),"utf8"),this.earliestAvailable=r.sequence_number+1}return t}replay(e){if(e===void 0)return{gap:null,events:this.events};const t=e<this.earliestAvailable-1||e>=this.nextSequence?A(this.earliestAvailable):null;return{gap:t,events:t===null?this.events.filter(s=>s.sequence_number>e):this.events}}get empty(){return this.events.length===0}}function A(n){return{type:"error",error:{code:"stream_gap",message:"the requested event cursor is older than this daemon can resume",details:{earliest_sequence:n}}}}o(A,"streamGap");function H(n){return n===null?"arguments: null":Array.isArray(n)?`arguments: array (${n.length} items)`:typeof n=="object"?`arguments: object (${Object.keys(n).length} fields)`:`arguments: ${typeof n}`}o(H,"toolSummary");function m(n){if(typeof n!="object"||n===null)return"";const e=n.content;return typeof e=="string"?e:Array.isArray(e)?e.filter(t=>typeof t=="object"&&t!==null).filter(t=>t.type==="text"&&typeof t.text=="string").map(t=>t.text).join(""):""}o(m,"assistantText");class w{static{o(this,"NodeEventTranslationState")}toolSummaries=new Map;emittedAssistantText="";resetAssistantText(){this.emittedAssistantText=""}emitAssistantTextDelta(e){const t=m(e),s=t.startsWith(this.emittedAssistantText)?t.slice(this.emittedAssistantText.length):t;return this.emittedAssistantText=t,s}seedAssistantText(e){const t=m(e);return this.emittedAssistantText=t,t}}function z(n,e,t=new w){switch(e.type){case"message_start":return e.message.role==="assistant"&&t.resetAssistantText(),[];case"message_update":{if(e.message.role!=="assistant")return[];const s=t.emitAssistantTextDelta(e.message);return s===""?[]:[{type:"node.output_text.delta",node_id:n,delta:s}]}case"message_end":{if(e.message.role!=="assistant")return[];const s=m(e.message);return t.resetAssistantText(),[{type:"node.output_text.done",node_id:n,text:s}]}case"tool_execution_start":{const s=H(e.args);return t.toolSummaries.set(e.toolCallId,s),[{type:"node.tool_call.started",node_id:n,tool_call_id:e.toolCallId,tool:e.toolName,summary:s}]}case"tool_execution_end":{const s=t.toolSummaries.get(e.toolCallId)??"[arguments unavailable]";return t.toolSummaries.delete(e.toolCallId),[{type:"node.tool_call.completed",node_id:n,tool_call_id:e.toolCallId,tool:e.toolName,status:e.isError?"error":"ok",summary:s}]}case"turn_start":return[{type:"node.turn.started",node_id:n}];case"turn_end":return[{type:"node.turn.completed",node_id:n}];default:return[]}}o(z,"translateBrokerFrame");class J{static{o(this,"NodeEventHub")}nodeId;remove;ring=new V;subscribers=new Set;observer;observerErrorAt;poll;reconnect;idleClose;status;settledRevision;settledOutcome;settledSequence;snapshotSeeded=!1;reportsSeeded=!1;translationState=new w;fresh=!0;constructor(e,t){this.nodeId=e,this.remove=t}subscribe(e,t){const s=this.fresh&&e!==void 0;this.fresh=!1,this.start();const r=this.settledOutcome!==void 0,d=s?{gap:A(1),events:this.ring.replay(void 0).events}:this.ring.replay(e);r||(this.subscribers.add(t),this.status!=="dormant"&&this.openObserver());let h=!1;return queueMicrotask(()=>{if(!h){d.gap!==null&&t(d.gap);for(const l of d.events)t(l)}}),r&&this.scheduleIdleClose(),{unsubscribe:o(()=>{h=!0,r||this.unsubscribe(t)},"unsubscribe"),terminal:r}}report(e){this.settledRevision===void 0&&this.publish({type:"node.report.pushed",node_id:this.nodeId,report:e})}start(){this.idleClose!==void 0&&(clearTimeout(this.idleClose),this.idleClose=void 0),this.poll===void 0&&(this.check(),this.settledRevision===void 0&&(this.seedReports(),this.poll=setInterval(()=>this.check(),x)))}seedReports(){if(!this.reportsSeeded){this.reportsSeeded=!0;for(const e of L(this.nodeId).reverse())this.report(e)}}check(){const e=b(this.nodeId);if(e===null){this.fail("stream_error",`node ${this.nodeId} no longer exists`);return}const t=M(e);if(t!==null){this.settledRevision!==t.revision&&(this.settledRevision=t.revision,this.settledOutcome=t,this.settledSequence=this.publish({type:"node.settled",node_id:this.nodeId,outcome:t}).sequence_number,this.stopObserver(),this.stopPoll());return}const s=e.pi_pid===null?"dormant":e.status;if(s==="dormant"){this.status!=="dormant"&&(this.status=s,this.publish({type:"node.status.changed",node_id:this.nodeId,status:s}));return}this.status===void 0?this.status=s:this.status!=="dormant"&&this.status!==s&&(this.status=s,this.publish({type:"node.status.changed",node_id:this.nodeId,status:s})),this.openObserver()}openObserver(){if(this.observer!==void 0||this.subscribers.size===0)return;const e=new C(this.nodeId);this.observer=e,e.on("connect",()=>{this.observerErrorAt=void 0;const t=b(this.nodeId);this.status==="dormant"&&t!==null&&t.pi_pid!==null&&(this.status=t.status,this.publish({type:"node.status.changed",node_id:this.nodeId,status:t.status})),e.send({type:"hello",role:"observer",client_id:q(),attends:!1})}),e.on("frame",t=>{try{t.type==="welcome"&&this.seedSnapshot(t.snapshot);for(const s of z(this.nodeId,t,this.translationState))this.publish(s)}catch(s){this.fail("stream_error",s instanceof Error?s.message:String(s))}}),e.on("error",()=>{const t=b(this.nodeId),s=Date.now();t!==null&&t.pi_pid!==null&&B(t.pi_pid)&&(this.observerErrorAt??=s,s-this.observerErrorAt<k)||this.fail("stream_error","observer connection failed")}),e.on("close",()=>{this.observer===e&&(this.observer=void 0,!(this.subscribers.size===0||this.settledRevision!==void 0)&&(this.status!=="dormant"&&(this.publish({type:"node.status.changed",node_id:this.nodeId,status:"dormant"}),this.status="dormant"),this.scheduleReconnect()))}),e.connect()}seedSnapshot(e){if(this.snapshotSeeded)return;this.snapshotSeeded=!0;const t=e.messages.filter(r=>r.role==="assistant"),s=e.state.isStreaming?t.pop():void 0;for(const r of t)this.publish({type:"node.output_text.done",node_id:this.nodeId,text:m(r)});s!==void 0&&this.publish({type:"node.output_text.delta",node_id:this.nodeId,delta:this.translationState.seedAssistantText(s)})}scheduleReconnect(){this.reconnect===void 0&&(this.reconnect=setTimeout(()=>{this.reconnect=void 0,this.check()},x))}publish(e){const t=this.ring.append(e);for(const s of[...this.subscribers])s(t);return t}fail(e,t){const s={type:"error",error:{code:e,message:t}};for(const r of[...this.subscribers])r(s);this.stopObserver(),this.stopPoll()}unsubscribe(e){this.subscribers.delete(e),this.subscribers.size===0&&this.scheduleIdleClose()}scheduleIdleClose(){this.idleClose===void 0&&(this.idleClose=setTimeout(()=>{this.stopObserver(),this.stopPoll(),this.remove()},$))}stopObserver(){this.reconnect!==void 0&&(clearTimeout(this.reconnect),this.reconnect=void 0);const e=this.observer;this.observer=void 0,e?.close()}stopPoll(){this.poll!==void 0&&(clearInterval(this.poll),this.poll=void 0)}}class W{static{o(this,"NodeEventStreams")}hubs=new Map;subscribe(e,t,s){let r=this.hubs.get(e);return r===void 0&&(r=new J(e,()=>this.hubs.delete(e)),this.hubs.set(e,r)),r.subscribe(t,s)}report(e,t){this.hubs.get(e)?.report(t)}}const F=new W;function K(n){const e=n.get("after");if(e===null||e==="")return{};if(!/^(?:0|[1-9]\d*)$/.test(e))throw new E(400,"invalid_request","`after` must be a non-negative integer");const t=Number(e);if(!Number.isSafeInteger(t))throw new E(400,"invalid_request","`after` must be a safe integer");return{after:t}}o(K,"parseQuery");function I(n){const{type:e,...t}=n;return`event: ${e}
1
+ var O=Object.defineProperty;var o=(n,e)=>O(n,"name",{value:e,configurable:!0});import{randomUUID as q}from"node:crypto";import{ApiError as E}from"../../../api/errors.js";import{BrokerClient as C}from"../../../core/broker-client/client.js";import{getNode as b}from"../../../core/canvas/canvas.js";import{isPidAlive as B}from"../../../core/canvas/pid.js";import{BROKER_VIEW_SOCKET_WAIT_MS as k}from"../../../core/runtime/view-socket.js";import{toNodeOutcomeDTO as M}from"../map.js";import{reportsForNode as L}from"./reports.js";const D=15e3,$=6e4,x=250,P=512,T=256*1024,j=1e3,G=32*1024*1024;class V{static{o(this,"NodeEventRing")}events=[];bytes=0;nextSequence=1;earliestAvailable=1;append(e){const t={...e,sequence_number:this.nextSequence++},s=Buffer.byteLength(JSON.stringify(t),"utf8");if(s>T)return this.events=[],this.bytes=0,this.earliestAvailable=this.nextSequence,t;for(this.events.push(t),this.bytes+=s;this.events.length>P||this.bytes>T;){const r=this.events.shift();if(r===void 0)break;this.bytes-=Buffer.byteLength(JSON.stringify(r),"utf8"),this.earliestAvailable=r.sequence_number+1}return t}replay(e){if(e===void 0)return{gap:null,events:this.events};const t=e<this.earliestAvailable-1||e>=this.nextSequence?A(this.earliestAvailable):null;return{gap:t,events:t===null?this.events.filter(s=>s.sequence_number>e):this.events}}get empty(){return this.events.length===0}}function A(n){return{type:"error",error:{code:"stream_gap",message:"the requested event cursor is older than this daemon can resume",details:{earliest_sequence:n}}}}o(A,"streamGap");function H(n){return n===null?"arguments: null":Array.isArray(n)?`arguments: array (${n.length} items)`:typeof n=="object"?`arguments: object (${Object.keys(n).length} fields)`:`arguments: ${typeof n}`}o(H,"toolSummary");function m(n){if(typeof n!="object"||n===null)return"";const e=n.content;return typeof e=="string"?e:Array.isArray(e)?e.filter(t=>typeof t=="object"&&t!==null).filter(t=>t.type==="text"&&typeof t.text=="string").map(t=>t.text).join(""):""}o(m,"assistantText");class w{static{o(this,"NodeEventTranslationState")}toolSummaries=new Map;emittedAssistantText="";resetAssistantText(){this.emittedAssistantText=""}emitAssistantTextDelta(e){const t=m(e),s=t.startsWith(this.emittedAssistantText)?t.slice(this.emittedAssistantText.length):t;return this.emittedAssistantText=t,s}seedAssistantText(e){const t=m(e);return this.emittedAssistantText=t,t}}function z(n,e,t=new w){switch(e.type){case"message_start":return e.message.role==="assistant"&&t.resetAssistantText(),[];case"message_update":{if(e.message.role!=="assistant")return[];const s=t.emitAssistantTextDelta(e.message);return s===""?[]:[{type:"node.output_text.delta",node_id:n,delta:s}]}case"message_end":{if(e.message.role!=="assistant")return[];const s=m(e.message);return t.resetAssistantText(),[{type:"node.output_text.done",node_id:n,text:s}]}case"tool_execution_start":{const s=H(e.args);return t.toolSummaries.set(e.toolCallId,s),[{type:"node.tool_call.started",node_id:n,tool_call_id:e.toolCallId,tool:e.toolName,summary:s}]}case"tool_execution_end":{const s=t.toolSummaries.get(e.toolCallId)??"[arguments unavailable]";return t.toolSummaries.delete(e.toolCallId),[{type:"node.tool_call.completed",node_id:n,tool_call_id:e.toolCallId,tool:e.toolName,status:e.isError?"error":"ok",summary:s}]}case"turn_start":return[{type:"node.turn.started",node_id:n}];case"turn_end":return[{type:"node.turn.completed",node_id:n}];default:return[]}}o(z,"translateBrokerFrame");class J{static{o(this,"NodeEventHub")}nodeId;remove;ring=new V;subscribers=new Set;observer;observerErrorAt;poll;reconnect;idleClose;status;settledRevision;settledOutcome;settledSequence;snapshotSeeded=!1;reportsSeeded=!1;translationState=new w;fresh=!0;constructor(e,t){this.nodeId=e,this.remove=t}subscribe(e,t){const s=this.fresh&&e!==void 0;this.fresh=!1,this.start();const r=this.settledOutcome!==void 0,d=s?{gap:A(1),events:this.ring.replay(void 0).events}:this.ring.replay(e);r||(this.subscribers.add(t),this.status!=="dormant"&&this.openObserver());let h=!1;return queueMicrotask(()=>{if(!h){d.gap!==null&&t(d.gap);for(const l of d.events)t(l)}}),r&&this.scheduleIdleClose(),{unsubscribe:o(()=>{h=!0,r||this.unsubscribe(t)},"unsubscribe"),terminal:r}}report(e){this.settledRevision===void 0&&this.publish({type:"node.report.pushed",node_id:this.nodeId,report:e})}start(){this.idleClose!==void 0&&(clearTimeout(this.idleClose),this.idleClose=void 0),this.poll===void 0&&(this.check(),this.settledRevision===void 0&&(this.seedReports(),this.poll=setInterval(()=>this.check(),x)))}seedReports(){if(!this.reportsSeeded){this.reportsSeeded=!0;for(const e of L(this.nodeId).reverse())this.report(e)}}check(){const e=b(this.nodeId);if(e===null){this.fail("stream_error",`node ${this.nodeId} no longer exists`);return}const t=M(e);if(t!==null){this.settledRevision!==t.revision&&(this.settledRevision=t.revision,this.settledOutcome=t,this.settledSequence=this.publish({type:"node.settled",node_id:this.nodeId,outcome:t}).sequence_number,this.stopObserver(),this.stopPoll());return}const s=e.pi_pid===null?"dormant":e.status;if(s==="dormant"){this.observerErrorAt=void 0,this.status!=="dormant"&&(this.status=s,this.publish({type:"node.status.changed",node_id:this.nodeId,status:s}));return}this.status===void 0?this.status=s:this.status!=="dormant"&&this.status!==s&&(this.status=s,this.publish({type:"node.status.changed",node_id:this.nodeId,status:s})),this.openObserver()}openObserver(){if(this.observer!==void 0||this.subscribers.size===0)return;const e=new C(this.nodeId);this.observer=e,e.on("connect",()=>{this.observerErrorAt=void 0;const t=b(this.nodeId);this.status==="dormant"&&t!==null&&t.pi_pid!==null&&(this.status=t.status,this.publish({type:"node.status.changed",node_id:this.nodeId,status:t.status})),e.send({type:"hello",role:"observer",client_id:q(),attends:!1})}),e.on("frame",t=>{try{t.type==="welcome"&&this.seedSnapshot(t.snapshot);for(const s of z(this.nodeId,t,this.translationState))this.publish(s)}catch(s){this.fail("stream_error",s instanceof Error?s.message:String(s))}}),e.on("error",()=>{const t=b(this.nodeId),s=Date.now();if(t!==null&&t.pi_pid!==null&&B(t.pi_pid)){if(this.observerErrorAt??=s,s-this.observerErrorAt<k)return;this.fail("stream_error","observer connection failed");return}this.observerErrorAt=void 0}),e.on("close",()=>{this.observer===e&&(this.observer=void 0,!(this.subscribers.size===0||this.settledRevision!==void 0)&&(this.status!=="dormant"&&(this.publish({type:"node.status.changed",node_id:this.nodeId,status:"dormant"}),this.status="dormant"),this.scheduleReconnect()))}),e.connect()}seedSnapshot(e){if(this.snapshotSeeded)return;this.snapshotSeeded=!0;const t=e.messages.filter(r=>r.role==="assistant"),s=e.state.isStreaming?t.pop():void 0;for(const r of t)this.publish({type:"node.output_text.done",node_id:this.nodeId,text:m(r)});s!==void 0&&this.publish({type:"node.output_text.delta",node_id:this.nodeId,delta:this.translationState.seedAssistantText(s)})}scheduleReconnect(){this.reconnect===void 0&&(this.reconnect=setTimeout(()=>{this.reconnect=void 0,this.check()},x))}publish(e){const t=this.ring.append(e);for(const s of[...this.subscribers])s(t);return t}fail(e,t){const s={type:"error",error:{code:e,message:t}};for(const r of[...this.subscribers])r(s);this.stopObserver(),this.stopPoll()}unsubscribe(e){this.subscribers.delete(e),this.subscribers.size===0&&this.scheduleIdleClose()}scheduleIdleClose(){this.idleClose===void 0&&(this.idleClose=setTimeout(()=>{this.stopObserver(),this.stopPoll(),this.remove()},$))}stopObserver(){this.reconnect!==void 0&&(clearTimeout(this.reconnect),this.reconnect=void 0);const e=this.observer;this.observer=void 0,e?.close()}stopPoll(){this.poll!==void 0&&(clearInterval(this.poll),this.poll=void 0)}}class W{static{o(this,"NodeEventStreams")}hubs=new Map;subscribe(e,t,s){let r=this.hubs.get(e);return r===void 0&&(r=new J(e,()=>this.hubs.delete(e)),this.hubs.set(e,r)),r.subscribe(t,s)}report(e,t){this.hubs.get(e)?.report(t)}}const F=new W;function K(n){const e=n.get("after");if(e===null||e==="")return{};if(!/^(?:0|[1-9]\d*)$/.test(e))throw new E(400,"invalid_request","`after` must be a non-negative integer");const t=Number(e);if(!Number.isSafeInteger(t))throw new E(400,"invalid_request","`after` must be a safe integer");return{after:t}}o(K,"parseQuery");function I(n){const{type:e,...t}=n;return`event: ${e}
2
2
  data: ${JSON.stringify(t)}
3
3
 
4
- `}o(I,"sseRecord");async function U(n){const e=n.params.id??"";if(b(e)===null)throw new E(404,"node_not_found",`no node ${e}`);const{after:t}=K(n.query),{req:s,res:r}=n;r.writeHead(200,{"content-type":"text/event-stream; charset=utf-8","cache-control":"no-cache",connection:"keep-alive"});let d=!1,h=!1,l=!1,c=0;const u=[];let v,f;const _=o(()=>{r.writableEnded||r.end()},"finish"),p=o(()=>{if(!d){if(d=!0,f!==void 0&&clearInterval(f),v?.(),s.destroyed||r.destroyed){u.length=0,c=0,_();return}if(l||u.length>0){h=!0;return}_()}},"close"),g=o(()=>{for(l=!1;u.length>0;){const i=u.shift();if(c-=Buffer.byteLength(i,"utf8"),!r.write(i)){l=!0,r.once("drain",g);return}}h&&_()},"flush"),N=o(()=>{if(d)return;u.length=0,c=0;const i=I({type:"error",error:{code:"stream_dropped",message:"stream reader is too slow"}});u.push(i),c=Buffer.byteLength(i,"utf8"),d=!0,h=!0,f!==void 0&&clearInterval(f),v?.(),l||g()},"dropSlowReader"),y=o(i=>{if(d)return!1;if(l||u.length>0){const a=Buffer.byteLength(i,"utf8");return u.length>=j||c+a>G?(N(),!1):(u.push(i),c+=a,!0)}return r.write(i)||(l=!0,r.once("drain",g)),!0},"write"),S=o((i,a)=>{d||y(I({type:"error",error:{code:i,message:a}})),p()},"fail"),R=o(i=>{try{if(!y(I(i)))return;(i.type==="node.settled"||i.type==="error"&&i.error.code!=="stream_gap")&&p()}catch(a){S("stream_error",a instanceof Error?a.message:String(a))}},"onEvent");f=setInterval(()=>{try{d||y(`: keepalive
4
+ `}o(I,"sseRecord");async function U(n){const e=n.params.id??"";if(b(e)===null)throw new E(404,"node_not_found",`no node ${e}`);const{after:t}=K(n.query),{req:s,res:r}=n;r.writeHead(200,{"content-type":"text/event-stream; charset=utf-8","cache-control":"no-cache",connection:"keep-alive"});let d=!1,h=!1,l=!1,c=0;const u=[];let v,f;const _=o(()=>{r.writableEnded||r.end()},"finish"),p=o(()=>{if(!d){if(d=!0,f!==void 0&&clearInterval(f),v?.(),r.destroyed){u.length=0,c=0,_();return}if(l||u.length>0){h=!0;return}_()}},"close"),g=o(()=>{for(l=!1;u.length>0;){const i=u.shift();if(c-=Buffer.byteLength(i,"utf8"),!r.write(i)){l=!0,r.once("drain",g);return}}h&&_()},"flush"),N=o(()=>{if(d)return;u.length=0,c=0;const i=I({type:"error",error:{code:"stream_dropped",message:"stream reader is too slow"}});u.push(i),c=Buffer.byteLength(i,"utf8"),d=!0,h=!0,f!==void 0&&clearInterval(f),v?.(),l||g()},"dropSlowReader"),y=o(i=>{if(d)return!1;if(l||u.length>0){const a=Buffer.byteLength(i,"utf8");return u.length>=j||c+a>G?(N(),!1):(u.push(i),c+=a,!0)}return r.write(i)||(l=!0,r.once("drain",g)),!0},"write"),S=o((i,a)=>{d||y(I({type:"error",error:{code:i,message:a}})),p()},"fail"),R=o(i=>{try{if(!y(I(i)))return;(i.type==="node.settled"||i.type==="error"&&i.error.code!=="stream_gap")&&p()}catch(a){S("stream_error",a instanceof Error?a.message:String(a))}},"onEvent");f=setInterval(()=>{try{d||y(`: keepalive
5
5
 
6
6
  `)}catch(i){S("stream_error",i instanceof Error?i.message:String(i))}},D),s.once("close",p);try{const i=F.subscribe(e,t,R);v=i.unsubscribe,i.terminal&&queueMicrotask(p)}catch(i){S("stream_error",i instanceof Error?i.message:String(i))}return{status:200}}o(U,"handleEvents");const ie=[{method:"GET",pattern:"/v1/nodes/:id/events",handler:U}];export{V as NodeEventRing,w as NodeEventTranslationState,ie as nodeEventRoutes,F as nodeEventStreams,z as translateBrokerFrame};
@@ -0,0 +1,18 @@
1
+ ---
2
+ title: SDK recipes
3
+ description: When composing crouter SDK namespaces into an application, read this because it helps you choose a complete runnable starting point.
4
+ ---
5
+
6
+ # SDK recipes
7
+
8
+ These recipes turn the SDK namespaces into complete applications: a bounded extraction, an assistant that waits for events, a research pipeline, a human approval step, and application-owned memory.
9
+
10
+ | Recipe | Use it when | It ends with |
11
+ |---|---|---|
12
+ | [Typed extraction](./typed-extraction.md) | You need one structured answer and no follow-up. | A typed object or an explicit failure. |
13
+ | [Event-driven assistant](./event-driven-assistant.md) | Events arrive over time from a webhook, queue, or watcher. | A resident node that wakes for each event. |
14
+ | [Fan-out pipeline](./fan-out-pipeline.md) | One task needs independent research before a synthesis. | An orchestrator's final report. |
15
+ | [Human approval](./human-approval.md) | A person must decide before work continues. | A node resumed by an inbox answer. |
16
+ | [Application memory](./app-memory.md) | Several runs need the same durable application knowledge. | A profile-owned document read by a scoped run. |
17
+
18
+ Start with typed extraction for a request/response job. Use a resident node when the same assistant should react again later. Use an orchestrator only when child work can proceed independently; otherwise keep the composition in your application.
@@ -0,0 +1,22 @@
1
+ ---
2
+ title: Application memory
3
+ description: When an application's agents need shared durable knowledge, read this because a profile store gives runs one owned memory location and scopes can allow reading without writing.
4
+ ---
5
+
6
+ # Application memory
7
+
8
+ Use this recipe when several runs need the same application guidance. Run `npx tsx examples/guides/app-memory.ts /path/to/repo`; it ensures a profile, creates a profile-owned knowledge document if it does not already exist, then starts a run allowed to read but not write memory.
9
+
10
+ <include lang="ts">../../../examples/guides/app-memory.ts</include>
11
+
12
+ A profile is the application's durable identity: it names the memory store and the projects whose context the profile can reach. The routing line is part of the document, not decoration. State both the moment the agent should read it and the reason it matters; a document with a vague routing line will not surface when the agent needs it.
13
+
14
+ The `boot`/`preview` surface exposes the document's routing line when this profile's agent starts. The task asks for a release note without naming `release/voice`; in the local run the agent read that document before producing the note. Without a surface entry, a memory document appears only in its directory listing and its routing line cannot select it at boot. The run passes `scopes: ['memory:read']`. That is an allow-list: node-targeted memory reads are allowed and writes are refused because `memory:write` is absent. The application itself is not narrowed by those node scopes, so keep application credentials and its memory mutation policy separate from the agent's capabilities.
15
+
16
+ Observed against the local daemon with `APP_PROFILE=release-notes-app-guide-boot-preview GUIDE_MODEL=openai-codex/gpt-6-sol:high`:
17
+
18
+ ```text
19
+ You can now download your invoices as a CSV from account settings.
20
+ ```
21
+
22
+ Use profile memory for knowledge shared across the application's related projects. Put facts that belong to one repository in project memory instead, and temporary run notes in node memory. See [memory](../../concepts/memory.md) for the ownership tiers and [profiles, kinds, and modes](../../concepts/profiles-kinds-and-modes.md) for what a profile changes.
@@ -0,0 +1,24 @@
1
+ ---
2
+ title: Event-driven assistant
3
+ description: When an assistant must react to later webhook, queue, or file-watcher events, read this because a resident node sleeps until nodes.message delivers the next event.
4
+ ---
5
+
6
+ # Event-driven assistant
7
+
8
+ Use this recipe for an assistant that reacts to an event source instead of ending after one request. Run `npx tsx examples/guides/event-driven-assistant.ts /path/to/repo`, then type events into standard input to stand in for a webhook handler or file watcher.
9
+
10
+ <include lang="ts">../../../examples/guides/event-driven-assistant.ts</include>
11
+
12
+ The external source owns detection. When it receives an event, it calls `nodes.message(nodeId, { body })`. `no_kickoff` leaves the resident node waiting for the first event, while `situational_context` tells it how to handle every event. A dormant resident node wakes for the message; a running node reads it at its next turn boundary. Keep the returned node id with the application so each later event reaches the same context and memory. The agent pushes one update report per event, which the application reads through `nodes.reports.list` and prints. The application checks for a fatal node fault rather than waiting forever for a report that cannot arrive.
13
+
14
+ The agent does not poll or schedule a timer: the daemon wakes it on each message, and it goes dormant after reporting. This console program polls for the report associated with the event it just sent; an application with its own event loop can also consume the node's event stream. Ctrl-C calls `nodes.cancel()` only because this interactive example needs an explicit way to stop.
15
+
16
+ Observed against the local daemon with `GUIDE_MODEL=openai-codex/gpt-6-sol:high` after sending `Build completed with 2 failing checks: lint and unit tests.`:
17
+
18
+ ```text
19
+ assistant node: 3zl47w7d-mud5amcx-67978c9b
20
+ Type an event and press Enter. Press Ctrl-C to stop the assistant.
21
+ The build completed, but **lint and unit tests failed**. Inspect the two failing check logs first to identify the errors.
22
+ ```
23
+
24
+ Use a resident node only when future events belong to the same ongoing assistant. For bounded work, use [typed extraction](./typed-extraction.md). See [lifecycle and wakes](../../concepts/lifecycle-and-wakes.md) for why waiting is free and [why a daemon](../../concepts/why-a-daemon.md) for why the process can outlive the terminal that started it.
@@ -0,0 +1,24 @@
1
+ ---
2
+ title: Fan-out pipeline
3
+ description: When independent parts of one job need separate agent work before one synthesis, read this because an orchestrator can spawn children, wait for reports, and publish a final result while the SDK streams progress.
4
+ ---
5
+
6
+ # Fan-out pipeline
7
+
8
+ Use this recipe when one result needs independent investigation first. Run `npx tsx examples/guides/fan-out-pipeline.ts /path/to/repo`; it asks an orchestrator to inspect a package through two children, prints live text and pushed reports, then prints the final reports retained on the node.
9
+
10
+ <include lang="ts">../../../examples/guides/fan-out-pipeline.ts</include>
11
+
12
+ `nodes.stream()` creates the orchestrator and observes it. It does not run the orchestration in your process: the node owns its children, waits for their pushed reports, and produces the synthesis. The stream gives your application live output and reports; `nodes.reports.list()` gives it the durable report view after settlement.
13
+
14
+ Keep orchestration inside the canvas when children need the canvas's report delivery, durable waits, and a parent that can be inspected or resumed. Keep it in your application when tasks are simple independent calls and the application already owns their scheduling and aggregation. Do not use an orchestrator merely because a task is long; it earns the extra structure only when child work can run independently.
15
+
16
+ A local run created `j6amiqxs-mud4drnk-330da196`; after its two children reported, `waitForOutcome()` returned:
17
+
18
+ ```text
19
+ { "kind": "result", "reason": "finalized" }
20
+ ```
21
+
22
+ The same run's `nodes.stream()` observer ended during a dormant gap before it could print this outcome. A separate daemon fix is correcting that observer behavior; the example remains the intended streamed shape, but its streamed path has not yet been observed end to end.
23
+
24
+ See [nodes and the canvas](../../concepts/nodes-and-the-canvas.md) for reports and durable identities, and [profiles, kinds, and modes](../../concepts/profiles-kinds-and-modes.md) for the base-versus-orchestrator decision.
@@ -0,0 +1,24 @@
1
+ ---
2
+ title: Human approval
3
+ description: When a person must approve or reject work before an agent continues, read this because the SDK can create an inbox request whose answer wakes the requesting node.
4
+ ---
5
+
6
+ # Human approval
7
+
8
+ Use this recipe when a decision belongs to a person, not a timeout or an agent guess. Run `npx tsx examples/guides/human-approval.ts /path/to/repo`; it prints an inbox ticket id. Answer the page in the crtr human inbox and the worker wakes with that answer before it reports its result.
9
+
10
+ <include lang="ts">../../../examples/guides/human-approval.ts</include>
11
+
12
+ The application creates the page through `client.human.requests.create()` and names the worker as `requester_node_id`. `delivery.reply: true` makes the settled answer travel back to that node. The page's `UserQuestion` supplies a known response shape, so the person sees an explicit approve or reject decision instead of an unstructured prompt.
13
+
14
+ The worker is terminal because this is a bounded approval run: it waits after setup, wakes when the person answers, then publishes its final result. The pending human request keeps that wait durable. The application does not poll the node or invent a fallback deadline; the human reply is a canvas event. `client.human.inbox` can list, inspect, and answer inbox tickets when your application provides its own human interface.
15
+
16
+ Observed against the local daemon after an approve response:
17
+
18
+ ```text
19
+ approval ticket: a643479ca7a79758bf86f32d24bd8c9b22e16509cd60996c5e4fe0bc5f097d45
20
+ Answer it in the crtr human inbox. The worker will wake with the response.
21
+ worker outcome: result
22
+ ```
23
+
24
+ See [lifecycle and wakes](../../concepts/lifecycle-and-wakes.md) for the wake model and [scopes and trust](../../concepts/scopes-and-trust.md) for why the daemon, rather than the application, owns delivery of the answer.
@@ -0,0 +1,22 @@
1
+ ---
2
+ title: Typed extraction
3
+ description: When an application needs one bounded structured answer with no follow-up, read this because nodes.parse returns a typed outcome union instead of hiding agent failures.
4
+ ---
5
+
6
+ # Typed extraction
7
+
8
+ Use this recipe to extract a small fact set from files in one working directory. Run it with `npx tsx examples/guides/typed-extraction.ts /path/to/repo`; it prints a package name and version or exits with an explicit agent outcome.
9
+
10
+ <include lang="ts">../../../examples/guides/typed-extraction.ts</include>
11
+
12
+ `nodes.parse()` creates a terminal root, waits for it, and returns the structured result typed from the Zod schema. The schema belongs at the boundary where your application consumes the answer, not in a second parser after the run.
13
+
14
+ Observed against the local daemon in this checkout:
15
+
16
+ ```text
17
+ package @north-light/crouter@0.3.332
18
+ ```
19
+
20
+ Handle every outcome arm. A result contains `output_parsed`; a decline says the agent could not honestly meet the schema; another failure carries its reason and detail. Transport and daemon failures still throw, so let those reach your normal application error handling.
21
+
22
+ This is the right shape for bounded work: one prompt, one answer, and no future message. If a user or external event must return to the same agent later, use a resident node instead. See [nodes and the canvas](../../concepts/nodes-and-the-canvas.md) for why an SDK node has durable identity, and [lifecycle and wakes](../../concepts/lifecycle-and-wakes.md) for the terminal-versus-resident decision.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.333",
3
+ "version": "0.3.335",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.333",
3
+ "version": "0.3.335",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.333",
9
+ "version": "0.3.335",
10
10
  "hasInstallScript": true,
11
11
  "license": "GPL-3.0-only",
12
12
  "workspaces": [
@@ -10859,28 +10859,28 @@
10859
10859
  },
10860
10860
  "packages/crouter-api": {
10861
10861
  "name": "@north-light/crouter-api",
10862
- "version": "0.3.333",
10862
+ "version": "0.3.335",
10863
10863
  "license": "GPL-3.0-only"
10864
10864
  },
10865
10865
  "packages/crouter-env-docker": {
10866
10866
  "name": "@north-light/crouter-env-docker",
10867
- "version": "0.3.333",
10867
+ "version": "0.3.335",
10868
10868
  "license": "GPL-3.0-only"
10869
10869
  },
10870
10870
  "packages/crouter-plugin": {
10871
10871
  "name": "@north-light/crouter-plugin",
10872
- "version": "0.3.333",
10872
+ "version": "0.3.335",
10873
10873
  "license": "GPL-3.0-only",
10874
10874
  "dependencies": {
10875
- "@north-light/crouter-api": "^0.3.333"
10875
+ "@north-light/crouter-api": "^0.3.335"
10876
10876
  }
10877
10877
  },
10878
10878
  "packages/crouter-sdk": {
10879
10879
  "name": "@north-light/crouter-sdk",
10880
- "version": "0.3.333",
10880
+ "version": "0.3.335",
10881
10881
  "license": "GPL-3.0-only",
10882
10882
  "dependencies": {
10883
- "@north-light/crouter-api": "^0.3.333"
10883
+ "@north-light/crouter-api": "^0.3.335"
10884
10884
  }
10885
10885
  }
10886
10886
  }