@not-another-harness/nah-js
v0.0.1-beta.9
Published
The standalone name for not-another-harness/js: a Web runtime for building agents in your own application. Mount the harness on any Request -> Response handler, stream its events over SSE, and let a paused turn resume on the next request.
Maintainers
Readme
nah-js
A Web runtime for not-another-harness — the standalone name for not-another-harness/js. Mount the harness on any handler that takes a Request and returns a Response, stream its events to the browser over server-sent events, and let a paused turn resume on the next request.
Install
npm install nah-js not-another-harness ai zod[email protected] is not on npm yet. Until it is, install the runtime from the SDK package and import it as not-another-harness/js — the same code, the same exports:
npm install not-another-harness ai zodThe runtime is implemented in the SDK package, at not-another-harness/js; this package re-exports it and holds no code of its own. Two names, one implementation — depend on nah-js if you would rather not import a subpath of the SDK, or on not-another-harness/js if you would rather keep a single dependency. The exports are identical either way.
nah-js is the transport: it decides how a run crosses the network. The agent — the model, the tools, the system prompt, the budget — stays yours, because that is the part an application should own.
There is no framework dependency. A route handler is a function that takes a Request and returns a Response, and that is the whole contract this package speaks.
Mount it
// app/api/nah/route.ts — Next.js
import { createNahHandler } from "nah-js";
import { anthropic } from "@ai-sdk/anthropic";
export const runtime = "nodejs";
export const maxDuration = 300;
export const { GET, POST } = createNahHandler({
agent: ({ sessionId }) => ({
model: anthropic("claude-sonnet-4-5"),
system: "You are a helpful assistant.",
tools: {},
}),
});Two handlers come back:
| Route | Method | What it does |
| --- | --- | --- |
| /api/nah | POST | Runs a turn, or steers or stops a run that is in flight. The response is an event stream. |
| /api/nah?sessionId=…&after=… | GET | Replays the frames a client missed, then follows along live. |
The agent factory is called once per turn, after the transcript has been loaded, so the model, the tools, or the system prompt can vary per conversation — and so the tools can be built against the workspace that conversation is about.
Read it in the browser
"use client";
import { useNahChat } from "nah-js/client";
export function Chat() {
const chat = useNahChat();
return (
<div>
{chat.messages.map((message) => (
<p key={message.id}>
<strong>{message.role}:</strong> {message.text}
</p>
))}
{chat.pendingApprovals.map((approval) => (
<button
key={approval.approvalId}
onClick={() => chat.approve(approval.approvalId, true)}
>
Run {approval.toolName}
</button>
))}
<button onClick={() => chat.send("hello")}>Send</button>
</div>
);
}nah-js/client is a separate entry point, so React never reaches a server bundle. The hook keeps the transcript, the pending decisions, and the status; it does not decide what your UI looks like.
The one thing that shapes the protocol
A turn can stop and be continued by a later request.
The harness ends a run with awaiting-approval when a tool needs a person's decision, and with suspended when a tool parked itself waiting for a value. Neither is an error: the run is paused. The transcript holds a call that never produced a result, and the only way forward is to run the harness again with the transcript and the answer.
So the transcript lives in a store rather than in the request, finish carries the pending decisions, and a resume is a fresh POST carrying the decision — not a second message on the same connection.
A consequence worth knowing: a decision and a new message cannot share a request. The harness reads a decision from the last message of the transcript only, so a request carrying both would push the decision out of the position the SDK reads — the approval would be silently ignored, the tool would never run, and the run would report success having done nothing. send and approve are separate calls for that reason, and the runtime refuses a request that tries to do both.
The wire format
Every frame is a JSON object with a type, a seq, the sessionId and the runId:
id: 12
event: text-delta
data: {"type":"text-delta","seq":12,"sessionId":"…","runId":"…","at":1730000000000,"step":1,"text":"Hello"}id carries the sequence number, so a client that reconnects can ask for everything after frame 42 and get it. event carries the type, so a browser's EventSource can dispatch by name. An event this runtime does not know about is skipped rather than crashing the connection.
The frames a client can receive are turn-start, run-start, step-start, text-delta, tool-call, tool-result, tool-approval-request, tool-approval-response, tool-suspended, tool-resumed, step-finish, compacted, wrap-up, user-message, finish, error and ping. They are typed in src/types.ts and exported from the package root.
Where the state lives
Two seams, both yours to fill:
session— where transcripts live between requests.load,appendandreplace;replaceexists because compaction means the transcript was rewritten, and a store that cannot represent that has to take the whole thing or lose history.events— where frames are recorded, so a reconnect can be answered. Separate from the session store because the two have different lifetimes: the transcript is the conversation, the log is a record of the delivery of its events.
Both default to in-memory implementations, which are right for a single long-lived Node server and wrong for anything else. On a serverless platform each request may land on a different instance, and a second instance sees an empty conversation. Implement the two interfaces against the store you already have.
Where it runs
The core speaks the Web platform's request and response objects, so the same runtime mounts in:
- Next.js — a route handler, as above.
- Hono, Remix, SvelteKit, Astro — an endpoint that receives a
Requestand returns aResponse. - Cloudflare Workers, Deno, Bun, Node — the same, with the store you have.
What a framework adds is its own plumbing, and that plumbing stays in the application:
// Next.js: work that should outlive the response.
import { after } from "next/server";
export const { GET, POST } = createNahHandler({
agent: () => ({ model, system, tools }),
after,
});after is optional. The runtime uses it for the bookkeeping it does after a turn ends, and falls back to running that bookkeeping inline when the handover is not available — a Next.js after throws when it is called outside the request scope, and a turn can end after the response was written.
Notes for a real deployment
- Write the transcript before the response finishes. A client that sends its next turn the moment it sees
finishmust find the transcript already there. The runtime does this; a custom session store should too. - The request's own
AbortSignalis the run's abort signal. A client that closes the tab stops the run rather than leaving it burning tokens for an answer nobody will read. - The follow stream ends when the turn it is following is over. A client that wants the next turn reconnects, which a browser's
EventSourcedoes on its own. - Errors are reduced to a name and a truncated message before they reach the wire — a stack trace or a provider message with a key in it does not belong in a browser. The original stays on the server, where the logs are.
Development
pnpm --filter nah-js test
pnpm --filter nah-js lint
pnpm --filter nah-js buildLicense
MIT
