← stitchkitDocumentation

Compose an agent application

Use these pieces when an application wants the existing headless harness and a generic conversation view. An application with its own AI SDK loop keeps mountAgent; a product with custom message projections can keep its transport and UI. No consumer migration is required. Model selection, storage, compaction, approvals and recovery stay with their existing owners.

Resources and tools

import {
  createAgentHarnessFileResources,
  createHeadlessAgentHarness,
} from 'stitchkit/agent-runtime/harness';
import { composeToolLifecycle, mountAgent } from 'stitchkit/tools';

const resources = createAgentHarnessFileResources({
  roots: [{ id: 'guide', kind: 'instruction', path: instructionRoot }],
  limits: { maxTotalBytes: 256 * 1024 },
});
const harness = createHeadlessAgentHarness({
  protocol, store, models, promptBudget,
  resources,
  tools: ({ context, run, toolFenceLifecycle }) =>
    mountAgent([catalogService], {
      context: { ...context, messageId: run.assistantMessageId },
      runtimeTools: resources.runtimeTools,
      lifecycle: composeToolLifecycle(authorizeTools, toolFenceLifecycle),
      hooks: observeTools,
    }),
});

tools is called for each run with its validated context. Mount through mountAgent and compose your authorization with the run’s toolFenceLifecycle by composeToolLifecycle: the fence rejects a call from a run that no longer owns the conversation, and it must run exactly once. Every mount option remains available, including registry, runtime tools, extension, coercion, output stripping, hooks and durability, and the callback may be async. The same callback still accepts arbitrary typed AI SDK tools and prepareStep. stitchkit/tools needs the same ai and @modelcontextprotocol/server peers as any mountAgent use; the base harness stays AI-only. The resource reader is explicitly selected: filtered allowlists do not gain tools merely because a loader exists. Root IDs, kinds, provenance and limits are unchanged.

Server and browser

stitchkit/agent-runtime/realtime and stitchkit/agent-runtime/react are evolving and experimental: no application has committed to them yet, so expect their shape to change.

import { bindAgentHarnessRealtime } from 'stitchkit/agent-runtime/realtime';

const binding = bindAgentHarnessRealtime(harness, socket, {
  async authorize({ identity, request, signal }) {
    // socket.data comes from your verified Socket.IO handshake, not a browser context.
    if (!await mayAccessConversation(identity, request, signal)) return null;
    return { context: { userId: identity.userId } };
  },
  onError: reportServerError,
});

// When an application withdraws someone's access, tell the binding.
await removeMember(conversationId, userId);
binding.revoke(conversationId, (identity) => identity.userId === userId);

socket is the existing createSocketIOServer handle, attached to createServer (Bun) or the Node adapter. The access policy checks every request, file/reference and metadata, and the attach that opens a stream. Events then flow under that grant without a call per token; Without a revoke() call a grant lasts until the socket disconnects or the conversation is detached: events keep flowing even if your own permission data changed. That fail-open default is deliberate (one check per request, not one per token), so call revoke whenever access is withdrawn. revoke ends the grant, the next event is re-authorized (source: 'delivery'), and a refusal or failure detaches the conversation and delivers access-denied, which puts the controller in error with code FORBIDDEN instead of leaving a frozen view. Session handshake authentication alone is insufficient. Conversation ownership and observer/controller roles are application policy; the canonical control server additionally enforces its exclusive controller lease.

import { createSocketIOClient } from 'stitchkit';
import { createAgentController } from 'stitchkit/agent-runtime/browser';
import { useAgent } from 'stitchkit/agent-runtime/react';

const transport = createSocketIOClient({ url: serverUrl, auth: sessionAuth });
const agent = createAgentController({ transport, conversationId, access: 'control' });
transport.connect();

function Conversation() {
  const state = useAgent(agent);
  const conversation = state.view.conversations[conversationId];
  // Render canonical messages/parts, transient progress, approvals and state.error.
  return <Transcript conversation={conversation} status={state.status} error={state.error} />;
}

await agent.request({
  operation: 'submit', idempotencyKey: crypto.randomUUID(),
  parts: [{ type: 'text', text: 'Find available items' }],
});
await agent.request({ operation: 'interrupt', runId });
await agent.request({ operation: 'respond-approval', approvalId, approved: true });
await agent.close(); // leaves transport and harness alive

Wait for ready before sending. Share a controller among views of the same conversation; create it at session scope, not during React render. Unmount only unsubscribes the view. To release the session, await close; to stop the application, separately close the binding, transport and harness through the application’s shutdown lifecycle. Closing a view does not interrupt its remote run. Use explicit interrupt for cancellation.

Reconnect reattaches and reads durable state. Mutations are not retried automatically: if an acknowledgement is lost, retain the original idempotency key. Incoming event buffers and outgoing requests have finite limits; capacity/overflow/lease failures stay visible, each with a code from the one AgentControlErrorCode vocabulary. A reconnect rejects the requests of the previous connection at once and attaches again, so they cannot hold capacity the new attach needs. A server overflow deliberately disconnects the transport; reconnect explicitly.

Check behavior, not just admission

import { inspectAgentRun } from 'stitchkit/agent-runtime/testing';

const ticket = harness.submit(input);
const admitted = await ticket.admission;
await ticket.result;
const result = inspectAgentRun(await harness.snapshot(input.conversationId), admitted.runId);
result.calledTool('catalog_search');
result.toolSucceeded('catalog_search');
result.notCalledTool('catalog_delete');
result.completed();

calledTool proves a call record (including an approved continuation linked through that run’s exact input messages); toolSucceeded also needs a matching successful result. completed requires successful terminal output with no pending approval. Prior runs, provider/policy stops, failed calls and unfinished runs cannot satisfy those assertions. The helper does not invoke a model or poll; the existing runtime ticket owns execution.