# The Five State Kinds — Vocabulary of a Relay

> Every relay is built from exactly five state kinds: agent (sub-agent with a prompt), script (shell command), poll (recurring external check), timer (pure wall-clock delay), and terminal (named business outcome). This page explains the invariants, input schema templating, and what each kind can and cannot do—including why integrations like GitHub or email are always script/poll states, never engine primitives.

- Repository: dzhng/duet-agent
- GitHub: https://github.com/dzhng/duet-agent
- Human wiki: https://grok-wiki.com/public/wiki/dzhng-duet-agent-82dbe2572d3a
- Complete Markdown: https://grok-wiki.com/public/wiki/dzhng-duet-agent-82dbe2572d3a/llms-full.txt

## Source Files

- `src/types/state-machine.ts`
- `src/turn-runner/shell-state-handle.ts`
- `examples/state-machine.ts`
- `evals/state-machine-tool-call-shape.eval.ts`
- `evals/outreach-lifecycle.eval.ts`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [src/types/state-machine.ts](src/types/state-machine.ts)
- [src/turn-runner/shell-state-handle.ts](src/turn-runner/shell-state-handle.ts)
- [src/turn-runner/state-machine-controller.ts](src/turn-runner/state-machine-controller.ts)
- [src/turn-runner/state-machine-session.ts](src/turn-runner/state-machine-session.ts)
- [examples/state-machine.ts](examples/state-machine.ts)
- [evals/outreach-lifecycle.eval.ts](evals/outreach-lifecycle.eval.ts)
- [evals/state-machine-tool-call-shape.eval.ts](evals/state-machine-tool-call-shape.eval.ts)
</details>

# The Five State Kinds — Vocabulary of a Relay

A relay (called a `StateMachineDefinition` in the codebase) is composed from exactly five state kinds: `agent`, `script`, `poll`, `timer`, and `terminal`. Every state in every relay definition must be one of these five. No sixth kind exists, and no external integration—email, GitHub, Slack, Calendly, webhooks—is a built-in primitive. The engine's deliberate design philosophy, stated directly in the type comments, is that "any external system with an API or CLI is a bash script away." That constraint is what makes the vocabulary minimal and the relay engine portable.

This page explains the invariants, input schema templating, and execution contract for each kind, then explores what each kind can and cannot do and why that boundary exists.

---

## The Common Base: `StateMachineBaseState`

Before examining each kind, every state shares a base structure:

```typescript
// src/types/state-machine.ts:249-260
export interface StateMachineBaseState {
  name: string;
  when?: string;
  inputSchema?: Record<string, unknown>;
}
```

- **`name`** — the string key the runner agent uses to select this state; recorded in every session history entry.
- **`when`** — optional guidance prose telling the runner agent when this state is appropriate. It is not evaluated by the engine; it is only injected into the runner's prompt so the LLM can make a better routing decision.
- **`inputSchema`** — an optional JSON Schema object. When present, the runner agent must supply a `Record<string, unknown>` that satisfies the schema when it selects this state. The controller validates the provided input and then stores it as `currentInput` on the session, making it available for template rendering.

### Template Rendering

Both `agent` and `script`/`poll` states accept `{{ input.fieldName }}` placeholders in their `prompt` or `command` fields. The engine renders these placeholders before execution using `renderTemplate` in `shell-state-handle.ts`:

```typescript
// src/turn-runner/shell-state-handle.ts:185-193
export function renderTemplate(template: string, input: Record<string, unknown>): string {
  return template.replace(TEMPLATE_PLACEHOLDER_PATTERN, (placeholder) => {
    const path = TEMPLATE_PLACEHOLDER_CAPTURE_PATTERN.exec(placeholder)?.[1];
    if (!path) return "";
    const value = readPath(input, path);
    if (value === undefined || value === null) return "";
    return typeof value === "string" ? value : JSON.stringify(value);
  });
}
```

Dot-path access is supported (e.g., `{{ input.reply.text }}`). Non-string values are serialized as JSON. Missing values render as empty string. This rendering happens immediately before execution, using `session.currentInput` as the source:

```typescript
// src/turn-runner/state-machine-controller.ts:267, 304
const prompt = renderTemplate(state.prompt, this.session?.currentInput ?? {});
const command = renderTemplate(state.command, this.session?.currentInput ?? {});
```

---

## The Five Kinds

### 1. `agent` — Sub-Agent with a Prompt

```typescript
// src/types/state-machine.ts:262-290
export interface StateMachineAgentState extends StateMachineBaseState {
  kind: "agent";
  prompt: string;
  systemPrompt?: string;
  allowedSkills?: string[];
  cwd?: string;
}
```

An `agent` state delegates execution to a fresh sub-agent turn. The controller calls `createStateAgent`, which builds a new turn-runner prompt from the rendered `prompt` field plus automatically injected history context, then runs the sub-agent to completion.

**What it can do:**
- Invoke tools (bash, read, write, edit) within `cwd`.
- Ask the user follow-up questions (surfaces as `state_asked_user` in history).
- Produce free-form textual output that becomes the state's completion payload.
- Scope its available skill set via `allowedSkills`.
- Operate in a different working directory than the parent runner.

**What it cannot do:**
- Directly select the next state — that belongs to the parent runner agent after the sub-agent completes.
- Run indefinitely without user interaction if the session needs to sleep; sleeping is only available in `poll` and `timer` states.

**Invariant:** The runner injects the original session prompt and relevant history into the sub-agent automatically. The state definition only describes the sub-agent's task-specific prompt. The comment in the type file is explicit: "The runner injects original prompt/history outside this state config."

**Example from the outreach eval:**

```typescript
// evals/outreach-lifecycle.eval.ts:66-80
{
  kind: "agent",
  name: "research_prospect",
  inputSchema: {
    type: "object",
    properties: {
      prospectName: { type: "string" },
      company: { type: "string" },
    },
    required: ["prospectName", "company"],
  },
  prompt:
    "Do not call tools. Write one concise research note for {{ input.prospectName }} from {{ input.company }}.",
},
```

The `{{ input.prospectName }}` and `{{ input.company }}` placeholders are filled from the runner's selected `input` before the sub-agent starts.

---

### 2. `script` — Shell Command (the Generic Integration Primitive)

```typescript
// src/types/state-machine.ts:292-308
export interface StateMachineScriptState extends StateMachineBaseState {
  kind: "script";
  command: string;
  cwd?: string;
  timeoutMs?: number;
  successCodes?: number[];
}
```

A `script` state runs an arbitrary shell command through `sh -lc`. This is intentionally the generic integration primitive for the relay engine. GitHub PRs, email sending, Slack notifications, Calendly links — all of these are `script` states, not engine primitives. The type file's comment is unambiguous: "Do not hardcode integrations such as email, GitHub, Slack, Calendly, or webhooks into the engine."

**What it can do:**
- Call any CLI tool (`gh`, `git`, `curl`, `sendmail`, custom scripts).
- Accept a `timeoutMs` to kill long-running commands.
- Treat non-zero exit codes as failure unless overridden in `successCodes`.
- Produce stdout captured as both raw string and parsed JSON (via `parseStructuredOutput`) in the completion payload.

**What it cannot do:**
- Sleep and retry automatically — that is `poll`'s job. A script either succeeds, fails, or times out in one shot.
- Interact with the user.

**Execution contract:** The controller spawns the command with `detached: true` so the process tree can be cleanly killed on interruption. On success, stdout is trimmed and parsed as JSON if possible, making downstream states able to read structured data from `input.parsed`:

```typescript
// src/turn-runner/state-machine-controller.ts:463-472
function normalizeStructuredShellOutput(shellOutput: ShellCommandOutput): ShellCommandOutput & {
  parsed: Record<string, unknown>;
} {
  return {
    ...shellOutput,
    stdout: shellOutput.stdout.trim(),
    stderr: shellOutput.stderr.trim(),
    parsed: parseStructuredOutput(shellOutput.stdout),
  };
}
```

**Example from the outreach eval:**

```typescript
// evals/outreach-lifecycle.eval.ts:82-97
{
  kind: "script",
  name: "send_outreach",
  inputSchema: { ... },
  command:
    'printf \'{"sent":true,"email":"{{ input.email }}","messageId":"eval-message-1",...}\'',
},
```

In a real deployment, this would be `scripts/send-email.sh '{{ input.email }}'` or similar.

---

### 3. `poll` — Recurring External Check

```typescript
// src/types/state-machine.ts:310-331
export interface StateMachinePollState extends StateMachineBaseState {
  kind: "poll";
  intervalMs: number;
  timeoutMs?: number;
  command: string;
  cwd?: string;
  successCodes?: number[];
}
```

A `poll` state is a `script` state that knows how to retry. The engine runs one poll attempt per wake, checks the exit code, and either completes (exit code in `successCodes`) or emits a `sleep` event scheduling the next attempt.

**What it can do:**
- Periodically poll any external system — PR status, email inbox, webhook delivery, CI pipeline — through any CLI or API wrapper.
- Capture structured JSON from stdout on success, identical to `script`.
- Time out the entire polling period with `timeoutMs`, not just a single attempt.

**What it cannot do:**
- Run multiple attempts in one wake. One wake = one attempt.
- Use a non-zero exit code to mean "found a result." Only exit codes in `successCodes` (default `[0]`) signal completion.

**Key invariant — exit code is king:**

The controller comment is explicit: stdout parsing does not affect poll completion. Only the exit code matters:

```typescript
// src/turn-runner/state-machine-controller.ts:356-374
// Poll success is determined purely by the script's exit code being
// in `successCodes` (default [0]). `shell.run()` resolves when the
// exit code is in the success set and rejects otherwise, so reaching
// this branch means "this poll attempt found a result." Stdout is
// parsed as JSON when possible for convenience, but the result of
// that parse does NOT affect whether the poll completes — only the
// exit code does.
const shellOutput = await shell.run();
const rawOutput = normalizePollShellOutput(shellOutput);
// ...
} catch (error) {
  // Exit code not in `successCodes` (or shell error) → keep polling.
  const wakeAt = Date.now() + state.intervalMs;
  this.session = recordStateSleep(this.requireSession(), state, wakeAt);
  return { type: "sleep", wakeAt };
}
```

**Timeout behavior:** The total elapsed time is checked at the start of each wake attempt using `elapsedSinceStateStarted`. If it exceeds `timeoutMs`, the state fails the entire session rather than attempting another poll:

```typescript
// src/turn-runner/state-machine-controller.ts:339-343
const elapsedMs = elapsedSinceStateStarted(this.session, state.name);
if (state.timeoutMs !== undefined && elapsedMs >= state.timeoutMs) {
  // ... fails the session
}
```

**Why integrations are always script/poll, never engine primitives:** The outreach lifecycle example from the type-file comments illustrates this clearly. Waiting for an email reply uses a `poll` state running a CLI wrapper — not a native email connector. This keeps the relay definition serializable (no functions, just data), keeps the engine BYOC-friendly (bring your own connector), and keeps polling latency acceptable (minutes, not milliseconds).

---

### 4. `timer` — Pure Wall-Clock Delay

```typescript
// src/types/state-machine.ts:333-338
export interface StateMachineTimerState extends StateMachineBaseState {
  kind: "timer";
  wakeAt: number;
}
```

A `timer` state carries exactly one field beyond the base: an absolute Unix epoch millisecond timestamp. When the engine reaches a timer state, if the target time is in the future, it emits a `sleep` event with `wakeAt` matching the specified timestamp. When the outer layer wakes the session at or after that time, the controller marks the state as completed and lets the runner agent choose what comes next.

**What it can do:**
- Enforce a fixed delay before a subsequent state (e.g., "wait 24 hours before sending a follow-up").
- Encode a specific calendar time as an epoch timestamp.

**What it cannot do:**
- Run any code or shell command.
- Carry any input schema; its `wakeAt` is static in the definition.
- Know what the next state will be — the parent runner decides after the timer completes.

**Execution contract:** The controller checks whether `wakeAt` is still in the future:

```typescript
// src/turn-runner/state-machine-controller.ts:381-393
private runTimerState(state: StateMachineTimerState, woke = false): StateMachineExecutionResult {
  if (!woke && state.wakeAt > Date.now()) {
    this.session = recordStateSleep(this.requireSession(), state, state.wakeAt);
    return { type: "sleep", wakeAt: state.wakeAt };
  }

  const output = {
    elapsedMs: elapsedSinceStateStarted(this.session, state.name),
    timestamp: Date.now(),
  };
  this.session = recordStateCompleted(this.requireSession(), state.name, output);
  return { type: "state_completed", stateName: state.name, output };
}
```

When it completes, the output carries `elapsedMs` (actual elapsed time) and `timestamp` (wall-clock completion time), which the next state can read via `input`.

**Example from the outreach eval:**

```typescript
// evals/outreach-lifecycle.eval.ts:95-98
{
  kind: "timer",
  name: "wait_for_reply",
  wakeAt: Date.now() + 60_000,
},
```

The eval sets a 60-second timer. The eval test confirms that after the first turn the session enters a `sleep` state at `wait_for_reply`, then resumes after the timer expires.

**`timer` vs `poll`:** Both emit `sleep` and both require the outer layer to wake the session. The distinction is that a `timer` knows exactly when to wake (fixed `wakeAt`) and runs no code at all. A `poll` recalculates its next wake on every failed attempt (`Date.now() + intervalMs`) and runs a shell command on each wake.

---

### 5. `terminal` — Named Business Outcome

```typescript
// src/types/state-machine.ts:340-347
export interface StateMachineTerminalState extends StateMachineBaseState {
  kind: "terminal";
  status: "completed" | "failed" | "cancelled";
  reason?: string;
}
```

A `terminal` state finalizes the session. When the runner agent selects a terminal state, the controller immediately records `terminal` on the session and returns `{ type: "terminal", status }` to the turn runner, which then closes the prompt loop and runs a final acknowledgment turn.

**What it can do:**
- Map a named business outcome (`meeting_scheduled`, `prospect_not_interested`, `merged`, `closed`) to one of three lifecycle statuses: `completed`, `failed`, or `cancelled`.
- Carry an optional `reason` string shown to users and recorded in history.
- Accept a caller-supplied override reason from the runner agent's decision when selecting it (for dynamic failure messages).

**What it cannot do:**
- Run shell commands.
- Wait for external events.
- Leave the session open. Reaching a terminal state is irreversible.

**Invariant — caller reason wins:** The controller resolves the final reason by preferring the runner agent's `decisionReason` over the state's static `reason`:

```typescript
// src/turn-runner/state-machine-controller.ts:396-407
private async runTerminalState(
  state: StateMachineTerminalState,
  decisionReason?: string,
): Promise<StateMachineExecutionResult> {
  const reason = decisionReason ?? state.reason;
  const terminal = { state: state.name, status: state.status, reason };
  this.session = recordStateMachineCompleted(this.requireSession(), terminal);
  return { type: "terminal", status: state.status, result: reason };
}
```

**Terminal states are not "error" or "done":** The type intentionally allows multiple named terminal states per relay. The outreach lifecycle has `meeting_scheduled` (completed), `prospect_not_interested` (completed), `negative_response` (failed), and `no_response_after_followups` (failed). Each carries semantic meaning for the business process, not just success/failure flags.

**Auto-injected escape hatches:** The eval comments note that "the runner auto-injects 'failed' and 'cancelled' terminal escape hatches, so the definition does not need to spell those out." This means every relay gets generic failure and cancellation terminals for free, without the author having to define them.

---

## Lifecycle and State Transitions

```text
StateMachineDefinition
  ├── agent states    → sub-agent executes → state_completed / ask / interrupted / failed
  ├── script states   → shell runs once   → state_completed / interrupted / failed (terminal)
  ├── poll states     → shell runs, then:
  │     exit in successCodes → state_completed
  │     exit not in successCodes → sleep(wakeAt = now + intervalMs) → retry on wake
  │     elapsed > timeoutMs → failed (terminal)
  ├── timer states    → wakeAt in future → sleep(wakeAt) → on wake → state_completed
  └── terminal states → immediately finalizes session → terminal(status, reason)
```

The session event log (`StateMachineSessionEvent`) captures every transition: `state_machine_started`, `runner_decided`, `state_started`, `state_completed`, `state_failed`, `state_interrupted`, `state_asked_user`, and `state_machine_completed`. This is an append-only audit log capped at 100 entries for long-running relays with many poll sleep cycles.

Sources: [src/turn-runner/state-machine-session.ts:23-24]()

---

## Comparison Table

| Kind | Runs code? | Can sleep/retry? | Can ask user? | Ends session? | Integration use |
|---|---|---|---|---|---|
| `agent` | Yes (via sub-agent tools) | No | Yes | No | Research, drafting, classification |
| `script` | Yes (shell, one-shot) | No | No | On failure | Email send, PR create, setup, cleanup |
| `poll` | Yes (shell, per-attempt) | Yes (intervalMs) | No | On timeout | PR status, inbox check, webhook wait |
| `timer` | No | Yes (fixed wakeAt) | No | No | Cadence delays, calendar scheduling |
| `terminal` | No | No | No | Always | Named business outcomes |

---

## Why Integrations Are Always `script` or `poll`

The type-file comments explain the philosophy directly:

> "Do not hardcode integrations such as email, GitHub, Slack, Calendly, or webhooks into the engine. Any external system with an API or CLI is a bash script away, and this engine can accept a few minutes of polling latency instead of requiring realtime responsiveness."

Sources: [src/types/state-machine.ts:17-20]()

The concrete benefit is that relay definitions remain plain serializable JSON/TypeScript data with no embedded logic. A relay can be stored in a database, transmitted over a wire, and resumed on a different process without any special deserialization. When an integration changes—e.g., switching email providers—only the script that a state references changes, not the engine or the state machine type system.

The outreach lifecycle eval demonstrates all five kinds working together in one relay: an `agent` for research, a `script` for email sending, a `timer` for delay, another `script` for reply fetching, another `agent` for classification, and a `terminal` for the named outcome `meeting_scheduled`. Sources: [evals/outreach-lifecycle.eval.ts:61-132]()
