Chio/Docs
LOGIN · JOIN

Model-call recovery

Journal each complete AI SDK provider response before its tools run, so a restarted worker resumes the original Chio operations.

Guide chio@5f63e6e

On this page

An AI SDK worker built on ChioProcessAgent survives a restart without asking the model to decide twice. The adapter saves each complete provider response before any of its tool calls execute, and the kernel admits each of those calls as a native operation of a Chio process.

A receipt records what happened to one tool operation; the response journal records the model decision that chose it. A provider asked again can return a new tool-call ID, which is a second operation. On restart the worker reuses the saved response and its tool-call IDs, and each call resolves its original kernel operation with its outcome and receipt.

Start
Two journals
native tool-call idsoriginal operationregenerated: new ids, different operationsjresponse journalcomplete, saveddadapterbinds the idsρkernel operationoutcome and receiptj′ρ′tool-call idsoriginal operationregeneratedjresponse journaldadapterρkernel operationoutcome and receiptj′ρ′

Recover the decision and its original tool operation

Two records survive the crash. The response journal keeps the model’s complete answer with its native tool-call identities, and the kernel keeps each tool operation with its outcome and receipt. The adapter maps one to the other, so the replacement resumes the same plan instead of asking the model again.

The response journal preserves the model's choice; the kernel preserves the tool outcome. A replacement reuses the complete saved response and its original tool-call identities; a regenerated response is drawn for contrast.

Drawn fromframeworks/ai-worker.ts

Resuming a turn takes both records: the journal holds the plan the model chose, and the kernel holds whether each tool in that plan ran.

1. Download the report worker and build it

In your own project the adapter is npm install @chio-protocol/ai-sdk-process @chio-protocol/process, beside ai and your provider package. To follow the steps here, download ai-sdk.zip, extract it, and work in ai-sdk/ on Linux with Chio, Python 3.11+, and Node 22+.

ai-sdk/bash
npm ci
npm run build

npm run build compiles ai-worker.ts and the separate ai-exercise.ts entry point; rebuild after changing either. The JavaScript files run the same application without a TypeScript build. The project holds the worker, one publication tool, and an explicit local test provider, and the framework README lists the exact dependency and build commands.

2. Run generation inside the adapter

The worker separates bootstrap validation, agent construction, and generation. The generation function passes the adapter's bindings to AI SDK inside agent.run.

ai-worker.ts · generateReporttypescript
async function generateReport(agent: ChioProcessAgent, input: WorkerInput) {
  return agent.run(async (bindings) => {
    const options = {
      ...bindings,
      prompt: input.prompt,
      maxRetries: 0,
      stopWhen: stepCountIs(3),
    };
    if (!input.streaming) {
      return (await generateText(options)).text;
    }

    const response = streamText(options);
    for await (const chunk of response.textStream) {
      // Displayed text is provisional until agent.run resolves.
      process.stdout.write(chunk);
    }
    return await response.text;
  });
}

Streaming text may reach the screen at once, but the run is complete only when agent.run resolves. The run plan fixes threadId, turnId, prompt, and modelKey for every attempt, so a replacement worker addresses the same turn.

The onReceipt callback saves each original signed receipt string. Recovery can append the same receipt again; an index can deduplicate verified operation identities while keeping the original text.

Complete TypeScript and JavaScript workers
ai-worker.tsai-worker.mjs
import { readFileSync, appendFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { generateText, streamText, stepCountIs } from "ai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { ProcessClient } from "@chio-protocol/process";
import {
  ChioProcessAgent,
  ProcessSuspendedError,
  type ProcessToolDefinition,
} from "@chio-protocol/ai-sdk-process";

interface WorkerInput {
  threadId: string;
  turnId: string;
  prompt: string;
  endpoint: string;
  model: string;
  modelKey: string;
  receipts: string;
  credentialEnv?: string;
  streaming?: boolean;
}

export interface Bootstrap {
  schema: string;
  attempt: number;
  connection: {
    socket_path: string;
    credential: string;
    tools: ProcessToolDefinition[];
  };
  input: WorkerInput;
}

export function readBootstrap(): Bootstrap {
  const bootstrap = JSON.parse(readFileSync(0, "utf8")) as Bootstrap;
  if (bootstrap.schema !== "chio.process.worker-bootstrap.v1") {
    throw new Error("Unsupported bootstrap");
  }
  const required = [
    "threadId",
    "turnId",
    "prompt",
    "endpoint",
    "model",
    "modelKey",
    "receipts",
  ] as const;
  for (const field of required) {
    if (typeof bootstrap.input[field] !== "string" || !bootstrap.input[field]) {
      throw new Error(`Missing input: ${field}`);
    }
  }
  return bootstrap;
}

function createAgent(bootstrap: Bootstrap, onReceiptSaved: () => void) {
  const { connection, input } = bootstrap;
  const provider = createOpenAICompatible({
    name: "report-provider",
    baseURL: input.endpoint,
    apiKey: input.credentialEnv ? process.env[input.credentialEnv] : undefined,
  });
  return new ChioProcessAgent({
    client: new ProcessClient(connection.socket_path, connection.credential),
    model: provider.chatModel(input.model),
    tools: connection.tools,
    namespace: "report-v1",
    threadId: input.threadId,
    turnId: input.turnId,
    modelKey: input.modelKey,
    maxModelCalls: 8,
    maxConcurrency: 1,
    onReceipt: (event) => {
      appendFileSync(input.receipts, `${event.result.receipt_json}\n`, {
        mode: 0o600,
        flush: true,
      });
      onReceiptSaved();
    },
  });
}

// region ai_run
async function generateReport(agent: ChioProcessAgent, input: WorkerInput) {
  return agent.run(async (bindings) => {
    const options = {
      ...bindings,
      prompt: input.prompt,
      maxRetries: 0,
      stopWhen: stepCountIs(3),
    };
    if (!input.streaming) {
      return (await generateText(options)).text;
    }

    const response = streamText(options);
    for await (const chunk of response.textStream) {
      // Displayed text is provisional until agent.run resolves.
      process.stdout.write(chunk);
    }
    return await response.text;
  });
}

// endregion ai_run

export async function runWorker(
  bootstrap: Bootstrap,
  onReceiptSaved: () => void = () => {},
) {
  try {
    const agent = createAgent(bootstrap, onReceiptSaved);
    const text = await generateReport(agent, bootstrap.input);
    console.log(JSON.stringify({ complete: true, text }));
  } catch (error) {
    if (error instanceof ProcessSuspendedError) {
      process.exitCode = error.exitCode;
    } else {
      const code = error instanceof Error ? error.name : "unknown_error";
      console.error(JSON.stringify({ complete: false, code }));
      process.exitCode = 2;
    }
  }
}

if (
  process.argv[1] &&
  import.meta.url === pathToFileURL(process.argv[1]).href
) {
  await runWorker(readBootstrap());
}

3. Run one turn against the local test provider

The default plan uses a scripted provider, so recovery needs no API key. Start it in a second terminal; it records every request in model.db.

ai-sdk/ · terminal 2bash
python3 model_server.py --port 8768

Back in the first terminal, authorize the report tool, run the worker, and verify its receipts. --local-tools starts the configured local command for discovery and signs its launch policy, which authorizes trusted local code without OS confinement.

ai-sdk/ · terminal 1bash
chio process init --config host.json --state .chio --local-tools
chio process run --state .chio --plan process.json
chio receipt verify --input .chio/receipts.ndjson \
  --trusted-kernel-pubkey .chio/kernel.pub

You should see the reporter complete in one attempt and one row in .chio/reports.db. Running the same plan again keeps the completed attempt.

Use your model provider

In a fresh copy of process.json, set the worker's endpoint, model, and modelKey, and set credentialEnv to the name of the environment variable that holds your API key. Keep the key itself out of the plan. The same worker calls the configured OpenAI-compatible endpoint, with no local test provider. Initialize fresh state for the changed input; a new prompt or model is a new turn.

4. Stop the worker after its first receipt and let it resume

Keep the test provider running. The recovery plan selects ai-exercise.ts, which runs the ordinary worker and exits after its first saved tool receipt. The replacement resumes the saved response and the original tool-call ID.

ai-sdk/ · recoverybash
chio process init --config host-recovery.json --state .chio-recovery --local-tools
chio process run --state .chio-recovery --plan process-recovery.json
chio process status --state .chio-recovery
chio receipt verify --input .chio-recovery/receipts.ndjson \
  --trusted-kernel-pubkey .chio-recovery/kernel.pub

You should see two reporter attempts and one publication in .chio-recovery/reports.db. The saved receipt repeats with its original signed bytes and verifies against the kernel key.

5. Count provider requests against publications

The framework workspace runs complete text, complete streaming, and a truncated stream with independent effect counts. Run exercise.py from the framework download to reproduce this capture and verify the original receipt signatures.

Captured output · AI SDK 6 fixturejson
[
  {
    "mode": "generate",
    "publications": 1,
    "provider_calls": 2,
    "complete": true
  },
  {
    "mode": "stream",
    "publications": 1,
    "provider_calls": 2,
    "complete": true
  },
  {
    "mode": "truncated-stream",
    "publications": 0,
    "provider_calls": 1,
    "complete": false
  }
]

You should see two provider responses for each successful run: one tool decision and one final answer. The deliberate restart adds no third provider request and no second publication. The truncated stream leaves one provider request and no tool effect.

Each moment of a turn leaves one durable record

A restart resumes from the last record written. The four moments of a turn, in order:

  1. Before the provider request. The model-call ordinal and request hash are reserved in the process checkpoint. A pending call is unknown, and the adapter sends no automatic provider retry.
  2. Complete provider response saved. Content, tool-call IDs, and metadata sit in the checkpoint or in immutable blobs. A restart replays the same response without contacting the provider.
  3. Tool admitted and completed. The kernel holds the operation outcome and its signed receipt. A restart recovers the same operation under its original key.
  4. Application sees the result. The application owns its output and any downstream acknowledgement, so it verifies output and reconciles UI delivery separately.

The auto storage mode keeps responses in immutable native blobs when the host advertises them, with small references in the 1 MiB checkpoint. A provider failure, a truncated stream, or a worker exit before the response checkpoint leaves a pending reservation, and the adapter stops rather than regenerate a different tool plan. Provider inference and billing stay with the provider.

Read more in Checkpoints and state.

Streamed text can arrive before the tool plan is durable

With streamText, text and reasoning flow to the UI as they arrive. The first tool event and the rest of that response wait for the final response to commit, and tool execution waits with them.

Streaming gate
text and reasoning, displayed as they arrivetool events heldcomplete response committedA truncated response leaves the gate closed.sprovider streamuUIKdoriginal callsdispatched oncetextheldresponse committedsprovider streamuUIKdoriginal calls

Visible text can precede a durable tool plan

Text and reasoning can reach the screen before the response is durable, so the UI owns deduplication if a later failure stops the run. The first tool event and everything after it wait for the complete response to be committed. Only then does the kernel dispatch the original tool calls.

Text may reach the UI while the response is still arriving. Native tool execution waits for the complete response to be retained; a truncated response leaves that gate closed. Token spacing is illustrative.

Drawn fromframeworks/ai-worker.ts

A later failure can stop a run after the UI has shown partial text, so the UI owns deduplication and reconciliation by its own stable turn identity. Consume the stream inside agent.run and await completion before reporting success; a stream returned from the callback would outlive its admission scope.

When a failure closes the run, the adapter drains work already admitted. With concurrent tool calls, one failed sibling leaves another sibling's committed effect in place. Model traffic, provider credentials, billing, and direct local callbacks sit outside the native tool guarantee.

ChioProcessAgent owns the response journal; ChioProcessTools uses yours

Both bridges mediate tools and retain native outcomes. They differ in who saves the model's response before the tools run.

ResponsibilityChioProcessAgentChioProcessTools
Save the complete response before toolsBuilt-in process journalYour application
Restore original tool-call IDsFrom the saved responseFrom your model history
Mediate tools, retain native outcomesYesYes
Retain receipts in your sinkOptional awaited onReceiptOptional awaited onReceipt
Verify signatures independentlyApplication or operatorApplication or operator
Reconcile text already shownApplicationApplication

Choose ChioProcessAgent unless your application already journals complete provider responses. AI SDK's onStepFinish runs after tool execution, too late to establish that journal.

A saved turn replays its tools without a provider

When your application retains the complete response, ChioProcessTools dispatches its saved calls directly. SavedTurn is the extracted tool plan: thread, turn, tool-call IDs, names, and arguments, all taken from that retained response.

replay-tools.tstypescript
import { ProcessClient, type Json } from "@chio-protocol/process";
import {
  ChioProcessTools,
  type ProcessToolDefinition,
  type ProcessReceiptEvent,
} from "@chio-protocol/ai-sdk-process";

interface Connection {
  socket_path: string;
  credential: string;
  tools: ProcessToolDefinition[];
}

// Application-owned journal record, saved before any of these tools execute.
export interface SavedTurn {
  threadId: string;
  id: string;
  toolCalls: Array<{ id: string; name: string; input: Json }>;
}

export async function replaySavedTurn(
  connection: Connection,
  savedTurn: SavedTurn,
  recordReceipt: (event: ProcessReceiptEvent) => void | Promise<void>,
) {
  const bridge = new ChioProcessTools({
    client: new ProcessClient(connection.socket_path, connection.credential),
    tools: connection.tools,
    namespace: "repository-review",
    threadId: savedTurn.threadId,
    turnId: savedTurn.id,
    onReceipt: recordReceipt,
  });

  return bridge.run(async ({ tools, abortSignal }) => {
    const results = [];
    for (const call of savedTurn.toolCalls) {
      const tool = tools[call.name];
      if (!tool?.execute) throw new Error(`Unknown saved tool: ${call.name}`);
      const result = await tool.execute(call.input, {
        toolCallId: call.id,
        messages: [],
        context: {},
        abortSignal,
      });
      results.push({ toolCallId: call.id, result });
    }
    return results;
  });
}

The caller loads savedTurn from its journal and supplies recordReceipt to save each original signed string. ChioProcessTools binds each native operation to namespace, thread, turn, and tool-call ID, and replaySavedTurn never calls a provider to recreate a call. Model decisions stay in your journal.

Read more in Calls and recovery.

Journal limits and failure behavior

Journal capacity

The reserved checkpoint property is chio.ai-sdk.journal.v1. Preserve it when writing application state, along with the cooperative-wait slot if that is enabled. Budget response history before a long turn starts.

OptionDefaultBound
maxModelCalls
64
Up to 128
Response limit
8 MiB
Up to 64 MiB
Checkpoint storage
1 MiB
Fixed at 1 MiB
Storage mode
auto
auto or inline

auto uses advertised immutable blobs; inline storage must fit the checkpoint. The example worker sets maxModelCalls: 8 and maxConcurrency: 1.

Journal error codes

  • model_configuration_invalid. An option value is invalid.
  • model_journal_full. A valid configuration has exhausted its capacity.
  • model_storage_unavailable. Explicit blob storage on a host that advertises no blob support.

Failure points and required behavior

Each failure keeps the original reservation; recovery reads what was retained.

Failure pointRetained stateRequired behavior
Provider connection drops before a complete response
Pending request hash and ordinal
Unknown model call; no automatic fresh inference
Stream truncates after text or partial tool arguments
Pending reservation; some UI text may exist
Held tools stay unexecuted; the UI marks the turn incomplete
Response checkpoint commits, acknowledgement lost
Possibly completed journal entry
Reread the original journal; replay the complete entry if retained
Prompt, schema, or model configuration changes
Original bound request
Refuse request drift before provider dispatch
Response blob missing or corrupt
Reference without usable history
Stop; never regenerate tool-call IDs to replace history
Blob or checkpoint quota exhausted
Original pending reservation can remain
Stop and preserve evidence; a larger in-memory limit leaves the old call as it was
Receipt sink fails after a tool commits
Tool outcome can be known
Outer run fails; recover the original call and the sink separately

Testing decisions and effects independently

  1. Restart. Run generateText and streamText on separate fresh hosts, crash after the receipt callback, and assert one database publication and the same original receipt after restart.
  2. Truncation. Use the fixture's truncated-stream mode and assert zero tool publications from the incomplete response.
  3. Drift. Change the original prompt only in an isolated drift test and assert refusal before a new provider request.
  4. Storage. Stop after a saved response, inspect its journal reference, and test a copy with a missing blob; recovery must refuse it.

Exercise copies only. A retained production journal stays as written.

Next: follow one operation key through recovery