norns-sdk 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ All notable changes to `norns-sdk` are documented in this file.
4
+
5
+ ## [0.2.0] - 2026-10-16
6
+
7
+ ### Added
8
+ - The worker joins with `features: ["message_cache"]` and keeps the message
9
+ list Norns last sent it per run, so Norns can send later steps as
10
+ `messages_delta` (the change) instead of the whole history. A delta on a
11
+ base the worker no longer holds is answered with `message_cache_miss` and
12
+ Norns resends in full. Older Norns ignores the feature and sends the full
13
+ list as before.
14
+
15
+ ## [0.1.0] - 2026-10-15
16
+
17
+ ### Added
18
+ - `NornsWorker`: connects over Phoenix Channels (phoenix.js), registers agents
19
+ and tools, serves `llm_task` and `tool_task`, reconnects and rejoins on its
20
+ own. LLM calls go through the AI SDK, with the provider inferred from the
21
+ model name or supplied via `models`. Gard claims, `registerPort()`, drain on
22
+ SIGTERM/SIGINT or `stop()`, and on-disk idempotency memory for
23
+ side-effecting calls.
24
+ - `tool()`: Zod-typed tools. The JSON Schema is derived from the schema,
25
+ `execute` receives the parsed input, and invalid input is reported to the
26
+ model as an error. Raw JSON Schema and `jsonSchema<T>()` work too.
27
+ - `defineAgent()`: a checked agent definition with every policy Norns
28
+ supports (`allowedTools`, `subagents`, `subagentConversation`,
29
+ `contextPolicy`, `maxTokens`).
30
+ - `NornsClient`: `sendMessage()` with `wait`, `stream()` and `runs.watch()`
31
+ as async iterables of typed events, `runs`, `agents` and `conversations`
32
+ resources, `runs.reply()` for parked runs. camelCase responses and typed
33
+ errors.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anson MacKeracher
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,263 @@
1
- # Temporary Holding Version
1
+ # norns-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/nornscode/norns-sdk-typescript/actions/workflows/ci.yml/badge.svg)](https://github.com/nornscode/norns-sdk-typescript/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/norns-sdk)](https://www.npmjs.com/package/norns-sdk)
5
+ [![Node 22+](https://img.shields.io/badge/node-22%2B-brightgreen)](https://nodejs.org)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
7
+
8
+ TypeScript SDK for [Norns](https://github.com/nornscode/norns), the durable agent runtime.
9
+
10
+ ```bash
11
+ npm install norns-sdk zod @ai-sdk/anthropic
12
+ ```
13
+
14
+ The SDK has two parts. `NornsWorker` connects to Norns, registers your agents, and serves their LLM and tool calls. `NornsClient` sends messages and reads results from application code: your web backend, Slack bot, CLI, whatever.
15
+
16
+ LLM calls go through the [AI SDK](https://ai-sdk.dev), so any `@ai-sdk/*` provider works; install the one your models need. Norns never sees your API keys. Your worker makes every external call.
17
+
18
+ ## Worker
19
+
20
+ ```ts
21
+ import { NornsWorker, defineAgent, tool } from "norns-sdk";
22
+ import { z } from "zod";
23
+
24
+ const searchDocs = tool({
25
+ name: "search_docs",
26
+ description: "Search product documentation.",
27
+ input: z.object({ query: z.string() }),
28
+ execute: async ({ query }) => db.vectorSearch(query),
29
+ });
30
+
31
+ const sendEmail = tool({
32
+ name: "send_email",
33
+ description: "Send an email to a customer.",
34
+ input: z.object({ to: z.string().email(), subject: z.string(), body: z.string() }),
35
+ sideEffect: true,
36
+ execute: async ({ to, subject, body }) => {
37
+ await smtp.send({ to, subject, body });
38
+ return `Email sent to ${to}`;
39
+ },
40
+ });
41
+
42
+ const supportBot = defineAgent({
43
+ name: "support-bot",
44
+ model: "claude-sonnet-5",
45
+ systemPrompt: "You are a customer support agent. Look up docs and help customers.",
46
+ tools: [searchDocs, sendEmail],
47
+ mode: "conversation",
48
+ });
49
+
50
+ const worker = new NornsWorker({ agents: [supportBot] });
51
+ await worker.start();
52
+ ```
53
+
54
+ `start()` registers the agents over REST, connects over WebSocket, and resolves once Norns has accepted the worker. The open socket keeps the process alive, so the rest of your program can carry on. The URL and key come from `NORNS_URL` and `NORNS_API_KEY`, or pass `url` and `apiKey`. LLM keys come from the provider's own variable (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, ...).
55
+
56
+ ### Models
57
+
58
+ An LLM task names a model, not a provider. By default the worker infers the provider from the name (`claude-*` is Anthropic, `gpt-*` is OpenAI, `gemini-*` is Google, `mistral-*` is Mistral) and loads its `@ai-sdk/*` package. Prefix the model (`"openai/gpt-4o"`) to pin one.
59
+
60
+ To take over, pass `models`: a provider, a registry from `createProviderRegistry()`, or a function from model name to model.
61
+
62
+ ```ts
63
+ import { createAnthropic } from "@ai-sdk/anthropic";
64
+
65
+ const worker = new NornsWorker({
66
+ agents: [supportBot],
67
+ models: createAnthropic({ baseURL: process.env.ANTHROPIC_PROXY }),
68
+ });
69
+ ```
70
+
71
+ A worker with no LLM access at all can still serve tools: `capabilities: ["tools"]`.
72
+
73
+ ### Gards
74
+
75
+ A [gard](https://github.com/nornscode/norns/blob/main/docs/gards.md) pins all of a run's tool dispatch to one worker: worker affinity for coding agents and other filesystem-bound work. Create one (`nornsctl gards create` prints the claim token once), then claim it:
76
+
77
+ ```ts
78
+ const worker = new NornsWorker({ agents: [coder], gard: 3, claimToken: "tok_..." });
79
+ ```
80
+
81
+ A worker in a gard serves only runs bound to that gard, and vice versa. Tools can expose service ports for the dashboard; the gard is inferred from the connection:
82
+
83
+ ```ts
84
+ execute: async ({ port }, ctx) => {
85
+ await startDevServer(port);
86
+ await ctx.worker.registerPort(port, { name: "react", url: `http://localhost:${port}` });
87
+ return "started";
88
+ }
89
+ ```
90
+
91
+ A fatally rejected claim (bad token, destroyed gard) rejects `start()` with `JoinError` instead of reconnect-looping. If the gard is destroyed while the worker is connected, `worker.stopped` rejects with `GardDestroyedError`.
92
+
93
+ ### Shutdown
94
+
95
+ On SIGTERM or SIGINT the worker drains instead of dying mid-task: it tells Norns to stop sending it work, finishes the tasks it holds, reports their results, leaves the channel, and the process exits. Tasks still running after `shutdownTimeoutMs` (default 30 seconds, or `NORNS_SHUTDOWN_TIMEOUT` in seconds) are dropped and Norns re-dispatches them. A second signal exits at once.
96
+
97
+ ```ts
98
+ const worker = new NornsWorker({ agents: [supportBot], shutdownTimeoutMs: 60_000 });
99
+ await worker.start();
100
+ await worker.stopped; // resolves after a drain
101
+ ```
102
+
103
+ `worker.stop()` requests the same drain from code; pass `handleSignals: false` to wire signals yourself. New work that arrives while a worker drains queues until its replacement connects, so a worker restarted by a supervisor loses nothing.
104
+
105
+ ## Client
106
+
107
+ ```ts
108
+ import { NornsClient } from "norns-sdk";
109
+
110
+ const client = new NornsClient({ url: "http://localhost:4000", apiKey: process.env.NORNS_API_KEY });
111
+
112
+ // Fire and forget
113
+ const { runId } = await client.sendMessage("support-bot", "Where's my order?");
114
+
115
+ // Wait for completion
116
+ const result = await client.sendMessage("support-bot", "Where's my order?", { wait: true });
117
+ console.log(result.output);
118
+
119
+ // Multi-turn with a conversation key
120
+ await client.sendMessage("support-bot", "And the tracking number?", {
121
+ conversationKey: "slack:U01ABC",
122
+ wait: true,
123
+ });
124
+
125
+ // Inspect a run
126
+ const run = await client.runs.get(42);
127
+ const events = await client.runs.events(42);
128
+
129
+ // Stream events as they happen
130
+ for await (const event of client.stream("support-bot", "Research quantum computing")) {
131
+ if (event.type === "tool_call") console.log("calling", event.data.name);
132
+ if (event.type === "completed") console.log(event.data.output);
133
+ }
134
+ ```
135
+
136
+ `event.type` narrows `event.data`, so the completed event's `output` is typed. `client.runs.watch(runId)` streams a run that is already going. Both end on `completed` or `error`, and throw `StreamClosedError` if the socket closes first.
137
+
138
+ Responses are camelCase (`run.agentId`, `run.waitingFor`). Errors are typed: `NornsApiError` carries the status and body, `NotFoundError` is the 404 case, `NornsTimeoutError` is a wait or stream that ran out of time.
139
+
140
+ ## Human-in-the-loop
141
+
142
+ An agent can call the built-in `ask_human` tool to pause and ask a question. The run parks with status `"waiting"` until someone answers, and survives a restart while parked.
143
+
144
+ ```ts
145
+ const result = await client.sendMessage("support-bot", "Book me a table", { wait: true });
146
+
147
+ if (result.isWaiting) {
148
+ console.log(result.waitingFor?.question); // "7pm or 8pm?"
149
+ await client.runs.reply(result.runId, "7pm");
150
+ }
151
+ ```
152
+
153
+ `wait` returns as soon as the agent parks: it is waiting on you, so it will not progress on its own. Sending the agent another message answers the question too, which is usually what a chat or Slack client wants; `runs.reply()` targets one specific run. To find parked runs nobody holds a handle to, `client.runs.list({ status: "waiting" })`.
154
+
155
+ ## Tools
156
+
157
+ `tool()` takes a [Zod](https://zod.dev) schema for `input`. The JSON Schema the model sees is derived from it, `execute` receives the parsed, typed object, and a call that fails validation is reported back to the model as an error rather than reaching your code.
158
+
159
+ ```ts
160
+ const lookupCustomer = tool({
161
+ name: "lookup_customer",
162
+ description: "Look up a customer by email.",
163
+ input: z.object({ email: z.string().email().describe("The customer's email") }),
164
+ execute: async ({ email }) => {
165
+ const customer = await db.customers.findByEmail(email);
166
+ return `Found: ${customer.name} (${customer.plan})`;
167
+ },
168
+ });
169
+ ```
170
+
171
+ Return a string, or anything JSON-serialisable. Raw JSON Schema works too (`input: { type: "object", ... }`), and `jsonSchema<T>()` from the SDK gives it a type. A tool with no `input` takes nothing.
172
+
173
+ `execute` gets a second argument with the call's context: `runId`, `agentId`, `taskId`, the `worker`, an `idempotencyKey`, and a `signal` that aborts when the worker gives up on the call (the socket dropped, or a drain hit its deadline). A long tool should pass the signal on.
174
+
175
+ ### Side effects happen once
176
+
177
+ Mark side-effecting tools so a call that is dispatched twice only happens once:
178
+
179
+ ```ts
180
+ const chargeCard = tool({
181
+ name: "charge_card",
182
+ description: "Charge a credit card.",
183
+ input: z.object({ customerId: z.string(), amount: z.number() }),
184
+ sideEffect: true,
185
+ execute: async ({ customerId, amount }, ctx) => {
186
+ const charge = await stripe.charges.create(
187
+ { customer: customerId, amount: Math.round(amount * 100) },
188
+ { idempotencyKey: ctx.idempotencyKey },
189
+ );
190
+ return `Charged $${amount}: ${charge.id}`;
191
+ },
192
+ });
193
+ ```
194
+
195
+ Norns names every side-effecting call with a key derived from the run, the step and the tool call id, and re-dispatches the call if the result never reached it. After a crash it has no way to know whether the charge went through. The worker does: it keeps the result against the key and answers the second dispatch from that, flagging it so the run's log records a `tool_duplicate` rather than what looks like a second charge. The memory is on disk (`NORNS_STATE_DIR`, or the system temp dir) and bounded, so it covers the orchestrator restarting and the worker restarting. For a worker that never comes back, pass the key on to a provider that dedupes, as above.
196
+
197
+ ## Agent options
198
+
199
+ ```ts
200
+ const agent = defineAgent({
201
+ name: "my-agent",
202
+ model: "claude-sonnet-5",
203
+ systemPrompt: "You are helpful.",
204
+ tools: [search, sendEmail],
205
+ mode: "conversation", // "task" or "conversation"
206
+ checkpointPolicy: "on_tool_call", // "every_step", "on_tool_call", "manual"
207
+ contextWindow: 20,
208
+ maxSteps: 50,
209
+ onFailure: "retry_last_step", // "stop" or "retry_last_step"
210
+ maxTokens: 8192, // ceiling on one response
211
+ allowedTools: ["search"], // offer only these of its tools (default: every tool)
212
+ subagents: { mode: "disabled" }, // launch_agent / list_agents policy (default: open)
213
+ });
214
+ ```
215
+
216
+ `maxTokens` is the ceiling on a single response, not on the history. Leave it unset and the worker uses 8192; raise it for an agent whose turns are long, such as one writing a whole file in a single turn. A turn that reaches the ceiling comes back truncated: the run completes, and the `llm_response` event carries `finish_reason: "length"` so a client can say so.
217
+
218
+ `defineAgent()` checks the combination where it is written: an `allowedTools` name the agent does not have, or a bad `subagentConversation`, throws before anything reaches the server.
219
+
220
+ ### Several agents, one worker
221
+
222
+ `agents` takes a list. Every agent is created or updated, and the worker registers the union of their tools. A tool call is dispatched by name, so two *different* tools sharing a name throw before the worker connects; the same tool in several agents is fine.
223
+
224
+ ```ts
225
+ const lead = defineAgent({
226
+ name: "lead",
227
+ tools: [readFile, writeFile],
228
+ subagents: { mode: "allowlist", allowedAgents: ["explore"], maxDepth: 1 },
229
+ });
230
+ const explore = defineAgent({
231
+ name: "explore",
232
+ tools: [readFile],
233
+ allowedTools: ["read_file"],
234
+ subagents: { mode: "disabled" },
235
+ subagentConversation: "per_launch",
236
+ });
237
+ const worker = new NornsWorker({ agents: [lead, explore] });
238
+ ```
239
+
240
+ An agent's `tools` are what the worker serves, not what the agent is offered: Norns offers every tool registered in the tenant unless `allowedTools` narrows it, so set it on any agent that should see less. The built-ins (`ask_human`, `wait`, `launch_agent`, `list_agents`) are never filtered.
241
+
242
+ `subagents` governs the last two: `mode` is `"open"`, `"allowlist"` (with `allowedAgents`) or `"disabled"`; `allowListAgents` permits `list_agents`; `maxDepth` bounds nesting. `subagentConversation` applies when the agent is launched by another: `"per_launch"` starts a fresh conversation each time, `"per_parent"` keeps one per parent conversation.
243
+
244
+ ## Examples
245
+
246
+ - [examples/support-bot.ts](examples/support-bot.ts): a worker with two tools
247
+ - [examples/client.ts](examples/client.ts): send, wait, answer a question, stream
248
+
249
+ Run one with `npx tsx examples/support-bot.ts` after `npm install`.
250
+
251
+ ## Development
252
+
253
+ ```bash
254
+ npm install
255
+ npm test # unit tests; set NORNS_URL and NORNS_API_KEY to run the integration tests too
256
+ npm run typecheck
257
+ npm run lint
258
+ npm run build
259
+ ```
260
+
261
+ ## License
262
+
263
+ MIT