HarnessServer
The route family behind createPleachRoutes — mount it into Next.js, Express, Hono, or node:http, and read what each HarnessServer handler returns.
HarnessServer is one surface in the frontend integration
thematic island —
siblings of react, api-routes,
query, and devtools. Wiring
surfaces, not concept triplets.
HarnessServer is a set of pure request-to-response handlers that
wrap a SessionRuntime. It does not bind a port. Each handler
mounts into whatever HTTP framework already owns your transport —
Next.js route segments, Express middleware, Hono routes, raw
node:http.
Most consumers don't touch HarnessServer directly. To mount the
whole hosted-agent route family from a published subpath, use
createPleachRoutes or the
higher-level createPleachAgent
façade — both from @pleach/core/quickstart. The HarnessServer +
ROUTES handler set described further down is the substrate those
factories run on. It isn't a public import; mount it through the
route factories, which hand you fetch, Next.js, and node:http
shapes for any framework.
The whole family: createPleachRoutes
createPleachRoutes mounts every route the runtime speaks — sessions
CRUD, execute+SSE, interrupts, checkpoints, rollback, events-recall,
sync, and fork — behind one dispatcher. It ships from the published
@pleach/core/quickstart subpath.
// app/api/harness/[...path]/route.ts (Next.js)
import { createPleachRoutes } from "@pleach/core/quickstart";
const routes = createPleachRoutes({ basePath: "/api/harness" });
export const { GET, POST, PUT, DELETE } = routes.nextRouteHandlers();basePath is stripped from the request path before the dispatcher
matches a route. It defaults to "", which suits a handler that sees
paths like /sessions/:id directly; a Next.js catch-all sees the full
URL, so set it to the segment's mount path.
The factory returns three mount shapes off one shared storage / checkpointer / event-store:
| Method | Returns |
|---|---|
routes.fetch(req) | A Web-standard (Request) => Promise<Response> handler |
routes.nextRouteHandlers() | { GET, POST, PUT, DELETE } for a Next.js catch-all segment |
routes.nodeHttp() | A (req, res) handler for node:http / Express |
The execute leg is delegated to
createPleachRoute unmodified, so streaming,
provider detection, and the error contract match the single-POST
handler exactly.
Opting out of legs
Pass routes: { <leg>: false } to omit a leg — for example if fork
and rollback aren't part of your product surface:
const routes = createPleachRoutes({
routes: { fork: false, rollback: false },
});Supplying a durable interrupt resolver
The default interrupt resolver binds the per-request runtime's
in-process InterruptManager, which is constructed fresh per request
and not rehydrated from storage. An interrupt raised during one
request's execute turn is therefore unknown to a later
POST .../interrupts/:id request handled by a different runtime — it
returns 404. The default is correct only when the pause and the
resolve share one runtime lifetime.
A durable, multi-request deployment supplies its own resolver via
capabilities.interruptResolver, backed by a store the resolve path
can consult:
const routes = createPleachRoutes({
capabilities: {
interruptResolver: myDurableInterruptResolver, // a DurableInterruptResolver
},
});capabilities.interruptResolver overrides the per-request default
binding. Its resolve receives the sessionId, so it can key a
cross-request store by (sessionId, interruptId).
sync, fork, and events are unaffected — they read through the
shared store and work across requests without an override.
The standalone agent: createPleachAgent
createPleachAgent is the three-arm façade over createPleachRoutes.
A consumer brings exactly three things — context (provider, model,
system prompt, plugins), tools (base tools included by default),
and db (one durable binding) — and gets the whole route family
back.
// app/api/harness/[...path]/route.ts (Next.js)
import { createPleachAgent } from "@pleach/core/quickstart";
const agent = createPleachAgent({
context: { provider: "anthropic", systemPrompt: "You are a helpful assistant." },
// tools defaults to the base-tools bundle
// db omitted → in-memory durable store (recall works, no Supabase)
});
export const { GET, POST, PUT, DELETE } = agent.nextRoutes();The return value carries the same three mount shapes as
createPleachRoutes (fetch, nextRoutes(), nodeHttp()) plus
agent.store — the resolved DurableStore for direct reads.
When db is omitted the agent uses
createMemoryDurableStore(), a zero-dependency
in-memory store. Recall, checkpoints, and the audit ledger all work
with no Supabase — state is lost on process restart, which is the
expected trade for a demo, a test harness, or a single-node dev loop.
Pass a db to persist.
The same per-request interrupt-resolver limitation applies. For a
durable multi-request deployment, pass interruptResolver to
createPleachAgent, which forwards it to the route family.
@pleach/core/server is not a published subpath. HarnessServer,
ROUTES, and createHarnessServer are real, but the ./server
export key isn't in @pleach/core's package.json, so they aren't
public imports. Everything an external consumer needs to mount the
route family is published under @pleach/core/quickstart: the two
factories above. The rest of this page describes the handler set
those factories run on, so you know what each route does.
HarnessServer is the substrate-level handler set, and
createPleachRoutes is its published mount. The route paths and
shapes on /docs/api-routes are what the
dispatcher serves.
Configuration
createPleachRoutes builds this config for you from its own options.
It's shown here so the route behavior below has a reference point.
interface HarnessServerConfig {
provider: ServerProvider; // executes messages, yields stream events
storage: ServerStorage; // session CRUD
checkpointer?: ServerCheckpointer; // enables checkpoints + rollback routes
auth?: ServerAuthProvider; // surfaces on /health features map
tools?: ToolRegistry; // enables /tools + /tools/:name
port?: number; // informational only
hostname?: string; // informational only
cors?: { origins, methods?, headers? };
}provider is the seam the execute routes call into — typically a thin
adapter that calls runtime.executeMessage(...) and yields each
stream event. storage mirrors the StorageAdapter shape but with
loosened types so the server stays decoupled from the full
SessionState envelope.
Calling start() flips an isRunning() flag and nothing else; it's
useful for health-check gating but binds no socket.
Tool introspection: createPluginToolRegistry
The tools? field wants a registry that answers /tools and
/tools/:name, not a raw tool array. createPluginToolRegistry
adapts a live tool-definition snapshot into that shape.
createPleachRoutes wires one for GET /tools automatically. The
factory is also exported from @pleach/core/quickstart for a host
that answers /tools from its own handler.
import { createPluginToolRegistry } from "@pleach/core/quickstart";
const tools = createPluginToolRegistry(() => runtime.resolvedTools());
// tools.listTools() → every tool as a ServerToolDefinition
// tools.getTool(name) → one tool, or nullIt takes a getter rather than a fixed array, so /tools reflects the
tool set as it stands per request — a plugin that registers a tool
mid-session shows up without rebuilding the registry.
Auto-binding from a runtime
createPleachRoutes binds the route capabilities from the runtime it
constructs: interrupts to runtime.interrupts.resolve, sync to
runtime.sync.execute, fork to runtime.timeTravel.api.fork, and
events from its shared event store. It does this through an internal
createHarnessServer, which isn't a public import.
To replace one of those bindings, pass it under capabilities:
import { createPleachRoutes } from "@pleach/core/quickstart";
const routes = createPleachRoutes({
basePath: "/api/harness",
capabilities: {
events: myDurableEventReader, // replaces the default in-memory reader
},
});capabilities also accepts verify, attest, memory,
interrupts, interruptResolver, sync, and timeTravel.
createInMemorySharedInterruptResolver — process-local reference resolver
The durable interrupt resolver
above is a seam (DurableInterruptResolver) — you supply the store.
createInMemorySharedInterruptResolver() is the reference implementation
of that seam for a single-process host: it holds pending interrupts in a
process-shared Map keyed by (sessionId, interruptId), so a resolve
issued in a later request finds the interrupt an earlier request registered.
Pass it as capabilities.interruptResolver on createPleachRoutes, or
as interruptResolver on createPleachAgent.
import {
createInMemorySharedInterruptResolver,
createPleachRoutes,
} from "@pleach/core/quickstart";
const interruptResolver = createInMemorySharedInterruptResolver();
const routes = createPleachRoutes({
basePath: "/api/harness",
capabilities: { interruptResolver },
});
// From the code that raises the pause, register a resume callback:
interruptResolver.register(sessionId, interruptId, async (decision) => {
if (decision.approved) await resumePausedTurn(sessionId, interruptId, decision);
});| Method | Purpose |
|---|---|
register(sessionId, interruptId, onResolve?) | Record a pending interrupt; onResolve fires when a later resolve matches. Idempotent per key. |
resolve(sessionId, interruptId, decision) | The DurableInterruptResolver.resolve seam. Matches a pending pair, fires its callback, and returns a SharedInterruptReceipt. |
isPending(sessionId, interruptId) | Whether a pending interrupt is registered for the pair. |
pendingCount() | Count of currently-pending interrupts. |
unregister(sessionId, interruptId) | Drop a pending interrupt without firing its callback — the reclaim path for an abandoned pause. |
The decision is SharedInterruptDecision ({ approved, note?, modifiedArguments? });
resolve returns SharedInterruptReceipt ({ handled, interruptId, resolvedDecision? })
where handled: false produces the same 404 "not pending" the default binding does.
SharedInterruptOnResolve is the callback type (decision) => void | Promise<void>.
This is process-local, not durable: the Map is heap-only, so it does
not survive a restart and does not share across instances or serverless
invocations that do not share a heap. A truly durable multi-instance host
supplies its own resolver over the same DurableInterruptResolver seam,
backed by a store both the pause and the resolve path consult. A host that
observes session teardown should unregister abandoned pauses to keep the
shared map from growing unbounded.
The ROUTES constant
ROUTES is the path table the dispatcher matches, relative to
basePath. It isn't exported publicly; the table below lists the
paths so a client can target them.
| Constant | Path | Handler |
|---|---|---|
ROUTES.HEALTH | /health | handleHealth |
ROUTES.SESSIONS | /sessions | handleCreateSession / handleListSessions |
ROUTES.SESSION | /sessions/:sessionId | handleGetSession / handleDeleteSession |
ROUTES.EXECUTE | /sessions/:sessionId/execute | handleExecuteMessage (SSE) |
ROUTES.EXECUTE_SYNC | /sessions/:sessionId/execute/sync | handleExecuteMessageSync (buffered JSON) |
ROUTES.INTERRUPT | /sessions/:sessionId/interrupts/:interruptId | handleResolveInterrupt |
ROUTES.CHECKPOINTS | /sessions/:sessionId/checkpoints | handleListCheckpoints |
ROUTES.ROLLBACK | /sessions/:sessionId/rollback | handleRollback |
ROUTES.TOOLS | /tools | handleListTools |
ROUTES.TOOL | /tools/:toolName | handleGetTool |
The Next.js handlers under /api/harness/* add an extra sync route
for version-vector merge that HarnessServer does not ship. If
sync is load-bearing, mount the Next.js adapter directly or proxy
the sync endpoint to your own implementation.
Handler signatures
These are the internal handlers the dispatcher calls. They describe what each route returns; you reach them over HTTP, not by import. Every handler returns one of two shapes:
interface HandlerResponse {
status: number;
body: unknown;
headers?: Record<string, string>;
}
interface SSEResponse {
status: number;
headers: Record<string, string>;
stream: AsyncIterable<string>; // already-formatted SSE frames
}HandlerResponse is for buffered JSON; SSEResponse is the streaming
path. The frame format the server emits is one of:
event: <type>
data: <json>
event: done
data: {}event: done is yielded at end-of-stream so a client can distinguish
clean close from disconnect without inspecting the underlying socket.
Session handlers
| Handler | Input | Returns |
|---|---|---|
handleCreateSession({ userId, config? }) | body | 201 + seeded SessionState |
handleGetSession({ sessionId }) | params | 200 + state, or 404 |
handleListSessions({ userId?, limit? }) | query | 200 + array |
handleDeleteSession({ sessionId }) | params | 204 |
handleCreateSession seeds the envelope (id, version: 1, empty
arrays for messages / tool calls / jobs / artifacts) and merges
config last. The id is a fresh crypto.randomUUID().
Execution handlers
| Handler | Response |
|---|---|
handleExecuteMessage | SSE stream of events from provider.execute |
handleExecuteMessageSync | Buffered JSON: { events, message, toolResults } |
handleExecuteMessage(params, body, request?) takes an optional third
argument — the transport's view of the HTTP request:
interface ExecuteRequestContext {
signal?: AbortSignal | null; // aborts when the client disconnects
headers?: Headers | Record<string, string | string[] | undefined> | null;
url?: string | URL | null;
}Pass it and a client disconnect aborts the turn, classified by the
intent the client declared in headers or url (see
Stopping a streamed turn from the client).
The turn gets a request-scoped abortSignal; a caller-supplied
options.abortSignal is composed in, not replaced. The disconnect
binding is released when the stream finishes, is cancelled, or the
request aborts, so a close after the turn ends is not a stop. Omit
request and nothing changes: no signal is injected.
handleExecuteMessageSync drains the provider stream and extracts the
message.complete event into message and every tool.completed
event into toolResults.
Through createPleachRoutes, neither handler serves the execute leg.
Both POST /sessions/:id/execute and POST /sessions/:id/execute/sync
go to createPleachRoute, so a non-streaming
client reads that route's stream to the end.
Checkpoint handlers
Both checkpoint routes return 501 when checkpointer is not
configured — the body is { error: "Checkpointer not configured" },
not a generic 500.
| Handler | Notes |
|---|---|
handleListCheckpoints({ sessionId }) | Drains the checkpointer's list async iterable into an array |
handleRollback({ sessionId }, { checkpointId }) | Reads the checkpoint, writes checkpoint.state back to storage.updateSession |
The rollback here is the wire-level operation — it replays the stored
state without the in-process bookkeeping (version-vector bump,
source: "rollback" checkpoint write) that
runtime.checkpoints.rollback does. If you need that bookkeeping,
mount the Next.js handler or call the runtime method directly behind
your own route.
Mounting examples
Each example mounts the whole family from @pleach/core/quickstart.
Next.js App Router
// app/api/harness/[...path]/route.ts
import { createPleachRoutes } from "@pleach/core/quickstart";
const routes = createPleachRoutes({ basePath: "/api/harness" });
export const { GET, POST, PUT, DELETE } = routes.nextRouteHandlers();Express
import express from "express";
import { createPleachRoutes } from "@pleach/core/quickstart";
const app = express();
// Express strips the mount path from req.url, so leave basePath unset.
const routes = createPleachRoutes();
// Mount before any body-parsing middleware: the handler reads the raw body.
app.use("/api/harness", routes.nodeHttp());routes.nodeHttp() pipes the execute stream chunk by chunk, so
streaming works through Express unchanged.
Hono
import { Hono } from "hono";
import { createPleachRoutes } from "@pleach/core/quickstart";
const routes = createPleachRoutes({ basePath: "/api/harness" });
const app = new Hono();
app.all("/api/harness/*", (c) => routes.fetch(c.req.raw));The pattern is the same in every framework: hand the request to
routes.fetch (Web-standard Request → Response) or
routes.nodeHttp() (node:http req/res), with basePath set to
whatever prefix the handler still sees.
Where to go next
API routes
The Next.js reference handlers with the full route catalog and SSE wire format.
React
The client-side hooks that consume these handlers over HTTP + SSE.
Query
Server-side read API over persisted harness data — pairs with these write-path handlers.
DevTools
Browser-console surface for inspecting what the server returns.
Orchestrator middleware
Wrap the model- and tool-call lifecycle with before/after interceptors. Two context-management middlewares ship — result eviction and history summarization.
API routes
The HTTP + SSE wire contract — eight routes that any runtime client speaks, with request/response shapes for each.