pleach
Build

HarnessServer

Framework-agnostic HTTP handlers wrapping the runtime — mount the routes into Next.js, Express, Hono, or any HTTP layer.

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 below is the lower-level substrate those factories are built on; reach for it only when you're wiring individual handlers into a framework Pleach doesn't ship an adapter for.

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, so no @pleach/core/server import is needed.

// app/api/harness/[...path]/route.ts (Next.js)
import { createPleachRoutes } from "@pleach/core/quickstart";

const routes = createPleachRoutes();
export const { GET, POST, PUT, DELETE } = routes.nextRouteHandlers();

The factory returns three mount shapes off one shared storage / checkpointer / event-store:

MethodReturns
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.interrupts, backed by a store the resolve path can consult:

const routes = createPleachRoutes({
  capabilities: {
    interrupts: myDurableInterruptResolver,
  },
});

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, drop to createPleachRoutes and supply capabilities.interrupts directly.

@pleach/core/server is not a published subpath today. HarnessServer + ROUTES are real classes, but they are an internal substrate surface — the ./server export key is not in @pleach/core's package.json, so the imports in the rest of this page resolve only inside the monorepo, not for an external consumer. Everything an external consumer needs to mount the route family is already published under @pleach/core/quickstart (the two factories above). This page documents the underlying handler set for reference and for the day the subpath is promoted.

// Internal substrate path — not a published @pleach/core subpath yet.
import {
  HarnessServer,
  ROUTES,
  type HarnessServerConfig,
} from "@pleach/core/server";

HarnessServer is the substrate-level handler set; the Next.js adapter that ships at /docs/api-routes is one mount. The route paths and shapes match — picking between them is about which framework owns your HTTP layer.

Configuration

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. It ships from the published @pleach/core/quickstart subpath, so an external consumer can wire tool introspection without the build-only @pleach/core/server path.

import { createPluginToolRegistry } from "@pleach/core/quickstart";

const tools = createPluginToolRegistry(() => runtime.resolvedTools());
// tools.listTools()      → every tool as a ServerToolDefinition
// tools.getTool(name)    → one tool, or null

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

createHarnessServer — auto-bind from a runtime

If you already hold a SessionRuntime, createHarnessServer wires the four route capabilities for you instead of hand-writing each HarnessServerConfig adapter:

// Build-only surface — not a published @pleach/core subpath.
import { createHarnessServer } from "@pleach/core/server";

const server = createHarnessServer({
  runtime,           // SessionRuntime — supplies interrupts/sync/fork facets
  storage,           // session CRUD
  checkpointer,      // optional — enables checkpoints + rollback
  events,            // optional — a reader for events-recall (omit → route 501s)
});

It binds interrupts to runtime.interrupts.resolve, sync to runtime.sync.execute, and fork to runtime.timeTravel.api.fork from the runtime's facets, and wires events from the passed reader. It returns a configured HarnessServer. This is the same wiring createPleachRoutes performs internally — reach for createHarnessServer only when you want the auto-bound handler set without the route dispatcher, and remember it shares the build-only status of the rest of @pleach/core/server.

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 createHarnessServer({ interruptResolver }).

// Build-only surface — not a published @pleach/core subpath.
import { createInMemorySharedInterruptResolver } from "@pleach/core/server";

const interruptResolver = createInMemorySharedInterruptResolver();
const server = createHarnessServer({ runtime, storage, 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);
});
MethodPurpose
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 canonical path table. Mount handlers against these strings so a client built from ROUTES and a server built from ROUTES stay in lockstep.

ConstantPathHandler
ROUTES.HEALTH/healthhandleHealth
ROUTES.SESSIONS/sessionshandleCreateSession / handleListSessions
ROUTES.SESSION/sessions/:sessionIdhandleGetSession / handleDeleteSession
ROUTES.EXECUTE/sessions/:sessionId/executehandleExecuteMessage (SSE)
ROUTES.EXECUTE_SYNC/sessions/:sessionId/execute/synchandleExecuteMessageSync (buffered JSON)
ROUTES.INTERRUPT/sessions/:sessionId/interrupts/:interruptIdhandleResolveInterrupt
ROUTES.CHECKPOINTS/sessions/:sessionId/checkpointshandleListCheckpoints
ROUTES.ROLLBACK/sessions/:sessionId/rollbackhandleRollback
ROUTES.TOOLS/toolshandleListTools
ROUTES.TOOL/tools/:toolNamehandleGetTool

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

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

HandlerInputReturns
handleCreateSession({ userId, config? })body201 + seeded SessionState
handleGetSession({ sessionId })params200 + state, or 404
handleListSessions({ userId?, limit? })query200 + array
handleDeleteSession({ sessionId })params204

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

HandlerResponse
handleExecuteMessageSSE stream of events from provider.execute
handleExecuteMessageSyncBuffered JSON: { events, message, toolResults }

handleExecuteMessageSync drains the provider stream and extracts the message.complete event into message and every tool.completed event into toolResults. Use it for non-streaming clients (cron jobs, batch workers, integration tests).

A buffered call from a batch worker, no SSE wiring:

const result = await server.handleExecuteMessageSync(
  { sessionId },
  { message: "Summarize today's queue." },
);

if (result.status === 200) {
  const body = result.body as { message: unknown; toolResults: unknown[] };
  await writeReport(sessionId, body.message, body.toolResults);
}

Checkpoint handlers

Both checkpoint routes return 501 when checkpointer is not configured — the body is { error: "Checkpointer not configured" }, not a generic 500.

HandlerNotes
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

Next.js App Router

// app/api/harness/[...path]/route.ts
// Internal substrate path — not a published @pleach/core subpath yet.
import { HarnessServer, ROUTES } from "@pleach/core/server";

const server = new HarnessServer({ provider, storage, checkpointer });
server.start();

export async function POST(req: Request, { params }: { params: { path: string[] } }) {
  const [resource, sessionId, action] = params.path;
  if (resource === "sessions" && !sessionId) {
    const body = (await req.json()) as { userId: string; config?: Record<string, unknown> };
    const r = await server.handleCreateSession(body);
    return Response.json(r.body, { status: r.status });
  }
  if (resource === "sessions" && action === "execute") {
    const body = (await req.json()) as { message: string; options?: Record<string, unknown> };
    const r = await server.handleExecuteMessage({ sessionId }, body);
    return new Response(toReadableStream(r.stream), { status: r.status, headers: r.headers });
  }
  // ... etc
}

Express

import express from "express";
// Internal substrate path — not a published @pleach/core subpath yet.
import { HarnessServer, ROUTES } from "@pleach/core/server";

const app = express();
const server = new HarnessServer({ provider, storage });

app.post(ROUTES.SESSIONS, async (req, res) => {
  const r = await server.handleCreateSession(req.body);
  res.status(r.status).json(r.body);
});

app.post(ROUTES.EXECUTE, async (req, res) => {
  const r = await server.handleExecuteMessage({ sessionId: req.params.sessionId }, req.body);
  res.writeHead(r.status, r.headers);
  for await (const frame of r.stream) res.write(frame);
  res.end();
});

Hono

import { Hono } from "hono";
import { streamSSE } from "hono/streaming";

const app = new Hono();
app.post(ROUTES.EXECUTE, (c) =>
  streamSSE(c, async (stream) => {
    const r = await server.handleExecuteMessage({ sessionId: c.req.param("sessionId") }, await c.req.json());
    for await (const frame of r.stream) await stream.write(frame);
  }),
);

The pattern is the same in every framework: route the framework's (req, params, body) into the matching handle* method, then serialize the HandlerResponse or SSEResponse back into whatever the framework expects.

Where to go next

On this page