crouter
SDKRecipes

Event-driven assistant

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.

Event-driven assistant

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.

import Crouter from '@north-light/crouter-sdk';
import { resolve } from 'node:path';
import { createInterface } from 'node:readline';

const client = new Crouter();
const cwd = resolve(process.argv[2] ?? process.cwd());

const assistant = await client.nodes.create({
  name: 'event assistant',
  cwd,
  model: process.env.GUIDE_MODEL,
  root: true,
  root_lifecycle: 'resident',
  no_kickoff: true,
  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.`,
});

console.log(`assistant node: ${assistant.node_id}`);
console.log('Type an event and press Enter. Press Ctrl-C to stop the assistant.');

const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
let stopping: Promise<void> | undefined;

function stop(): Promise<void> {
  if (stopping !== undefined) return stopping;
  input.close();
  stopping = client.nodes.cancel(assistant.node_id).then(() => undefined);
  return stopping;
}

process.once('SIGINT', () => {
  void stop().catch((error: unknown) => {
    console.error(error);
    process.exitCode = 1;
  });
});

for await (const line of input) {
  if (stopping !== undefined) break;
  if (line.trim() === '') continue;
  try {
    const previous = (await client.nodes.reports.list(assistant.node_id, { limit: 1 }))[0]?.path;
    if (stopping !== undefined) break;
    await client.nodes.message(assistant.node_id, { body: line });
    while (stopping === undefined) {
      const report = (await client.nodes.reports.list(assistant.node_id, { limit: 1 }))[0];
      if (stopping !== undefined) break;
      if (report !== undefined && report.path !== previous) {
        console.log(report.body);
        break;
      }
      const state = await client.nodes.retrieve(assistant.node_id);
      if (stopping !== undefined) break;
      if (state.fault?.retry.disposition === 'fatal' || state.status === 'dead' || state.status === 'canceled') {
        throw new Error(`assistant stopped without reporting: ${state.fault?.message ?? state.status}`);
      }
      await new Promise((resolve) => setTimeout(resolve, 1000));
    }
  } catch (error) {
    if (stopping === undefined) throw error;
  }
}

await stop();

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.

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.

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

assistant node: 3zl47w7d-mud5amcx-67978c9b
Type an event and press Enter. Press Ctrl-C to stop the assistant.
The build completed, but **lint and unit tests failed**. Inspect the two failing check logs first to identify the errors.

Use a resident node only when future events belong to the same ongoing assistant. For bounded work, use typed extraction. See lifecycle and wakes for why waiting is free and why a daemon for why the process can outlive the terminal that started it.