Lampson as an engine
SDK
npm i lampson # JavaScript / TypeScript (Node 18+), no dependencies
pip install lampson # Python 3.9+, standard library only
Both have the same methods and two modes. You change one line to switch from one to the other; the conversations are the same.
The two modes§
Server — your app talks to a Lampson that's already running (your PC with the hub, a VPS, Docker) over the HTTP API, with a URL and an API key. For when Lampson is on another machine or serves many clients.
Local — the SDK starts Lampson itself, as a child process on your machine, and talks to it over lampson run --stdio. No hub, no port, no key, no public URL: Lampson inside your program, like SQLite. For a nightly script that analyzes a database, a desktop app, a CI step.
JavaScript / TypeScript§
import { Lampson } from "lampson"; // or: const { Lampson } = require("lampson")
const lampson = new Lampson({ url: "https://your-lampson/w/tienda", key: process.env.LAMPSON_KEY }); // server
// const lampson = new Lampson({ local: { workspace: "./my-project" } }); // local
const r = await lampson.ask({ agent: "tienda", thread: "cliente-42", text: "do you have whole-wheat bread?" });
console.log(r.text);
for await (const ev of lampson.stream({ agent: "tienda", thread: "cliente-42", text: "and the price?" })) {
if (ev.kind === "tool_call") console.log("using", ev.data.name);
if (ev.kind === "done") console.log(ev.data.text);
}
await lampson.close();
TypeScript types included. Local mode uses the package's own Lampson: npm i lampson is enough (it installs Synsema as a dependency).
Python§
from lampson import Lampson
with Lampson(local={"workspace": "./my-project"}) as lampson: # or Lampson(url=..., key=...)
r = lampson.ask(agent="tienda", thread="cliente-42", text="do you have whole-wheat bread?")
print(r["text"])
for ev in lampson.stream(agent="tienda", thread="cliente-42", text="and the price?"):
if ev["kind"] == "done":
print(ev["data"]["text"])
Local mode in Python runs Lampson, which needs Node.js: if the lampson command is installed (npm i -g lampson or the installer) it uses that; if not, it fetches it once with npx, at the same version as the Python package. Without Node, the error says so. Another command with local={"workspace": "…", "command": ["node", "node_modules/lampson/bin/lampson.js"]}; a relative path there is your app's, not the workspace's.
An agent for your app§
When the agent serves your customers or your users, it should have only the tools you gave it:
const lampson = new Lampson({
agent: "support", // the one that answers when a message does not say `agent`
local: { workspace: "./shop", home: "./.lampson-home", lampsonTools: false },
});
await lampson.ask({ thread: "customer-42", text: "where is my order?" }); // support answers
await lampson.ask({ agent: "sales", thread: "customer-42", text: "…", mode: "strict" }); // another agent and mode
lampson = Lampson(agent="support", local={"workspace": "./shop", "home": "./.lampson-home", "lampson_tools": False})
agent: this client's default agent. Each message can change it. Without it,buildanswers: Lampson's coding agent, with all its tools.lampsonTools: false(lampson_toolsin Python, local mode only): no tool of Lampson's own exists in that process — nobash,read,writeorfetch— not even for the built-in agents. Your agents have only the ones you give them (plugins, MCP). A message that tries to make the agent run a command has nothing to run it with.home: where Lampson keeps its config and sessions (default~/lampson). A relative path is relative to your app, not to the project.- Warnings: if an agent lists a tool it will not have (a plugin that is off, a typo), the SDK says so at start with
console.warn(Python: a line on stderr). Change it withonWarning(on_warning), ornull(None) to silence it; the list stays inlampson.warnings. close()ends every process it started. Local mode never touches a Lampson already running on the machine.
Your app's tools§
An agent that serves your customers needs your tools: look up an order, check stock, make a booking. There are two ways, and the agent uses both the same: you name them in its tools list.
In your language (JavaScript or Python)§
The function runs inside your app, with your database and your auth. It is the AI SDK shape: tools you already wrote with zod paste in as they are. With zod 4 any schema works; with zod 3, the common ones (objects, strings, numbers, booleans, enums, arrays, optional, nullable and describe), and for the rest install zod-to-json-schema.
lampson.tool("stock", {
description: "Units in stock of a product",
inputSchema: z.object({ product: z.string() }), // zod (validated before execute) or a JSON Schema
execute: async ({ product }, ctx) => db.stock(product, { store: ctx.meta.store }),
});
// or several: lampson.tools({ stock: {...}, book: {...} })
@lampson.tool
def stock(product: str, ctx: dict) -> dict:
"""Units in stock of a product.""" # the docstring is the description
return db.stock(product, store=ctx["meta"]["store"])
The schema comes from the type hints (str, int, bool, Literal, Optional, a Pydantic model) and the defaults.
With Lampson on another machine (server mode) the tool code is the same; the constructor changes and you expose an endpoint. The tools travel with each message and Lampson POSTs each call to your app, signed (RFC 9421) with your key's secret:
export const lampson = new Lampson({
url: process.env.LAMPSON_URL, key: process.env.LAMPSON_KEY,
toolsUrl: "https://my-app.com/api/lampson/tools", // the PUBLIC URL of the endpoint below
toolsSecret: process.env.LAMPSON_TOOLS_SECRET, // the whsec_… `lampson key add` showed
});
lampson.tool("stock", { … }); // same as in local mode
// app/api/lampson/tools/route.ts (Next, Hono, Bun…): verifies the signature, runs the tool, answers
export const POST = lampson.toolsHandler();
// Express: app.post("/api/lampson/tools", lampson.toolsHandler()) (before express.json())
lampson = Lampson(url=..., key=..., tools_url="https://my-app.com/lampson/tools", tools_secret=os.environ["LAMPSON_TOOLS_SECRET"])
app.mount("/lampson/tools", lampson.tools_asgi()) # FastAPI / Starlette
# another framework: status, body = lampson.handle_tool_request(method, headers, raw_body)
- The URL must be in the key's list:
lampson key add --callback https://my-app.com/api/lampson/(no list, no remote tools). And it must behttps, exceptlocalhost. - Lampson makes the POST under a Synsema ceiling with that URL's host: even if something goes wrong, it cannot go anywhere else.
- If your app does not answer in time (
timeout, 60 s by default), the model gets an error and the call is not retried. A tool that charges or cancels never runs twice; each request carries a signedcall_id, and the SDK handler answers 409 to a repeated one. On serverless or with several workers, if the tool charges or changes something, give it a shared store:toolsHandler({ seen })/tools_asgi(seen=...)(with Redis:SET call:<id> 1 NX EX 600). If the store fails, it answers 503 and does not run. - Watch out for a single-worker server (Flask in development,
uvicornwith 1 worker and blocking code): if that worker is waiting on anask, it cannot serve the tool call, and both wait forever. Node, async FastAPI and serverless do not have the problem. - Behind a proxy that rewrites the host, the handler verifies against
toolsUrl, not the URL it received.
| JS/Python function | .syn tool | |
|---|---|---|
| where it runs | in your app | Lampson runs it |
| with your app down (scheduled tasks, teams) | no | yes |
| what it can touch | whatever your app can | only its requires |
In Synsema: with guarantees§
One file per tool in your project's .lampson/tools/ (another folder inside the project with local: { tools: "./tools" }):
-- .lampson/tools/order_status.syn
intent: "Status of an order of the customer who writes"
require net("api.myshop.com") -- everything this tool can touch
require env("LAMPSON_CTX") -- it wants to know who is writing
export task order_status(id, with_items = false)
let ctx be json_decode(env("LAMPSON_CTX", "{}"))
let r be http_get("https://api.myshop.com/orders/" + id + "?customer=" + ctx["from"]["id"])
give r["json"]
- The file name is the tool name, and the file must export a
taskwith that name. - The description is its
intent:and the parameters are the signature's: one with a default is optional. - Its
requires are everything it can touch. If a message gets it to send data to another host, Synsema denies it (Capability not granted), even when the model got it wrong. A JavaScript or Python function cannot promise that. - They go at the top of the file: one written inside the
taskis denied.env(…)only with exact names (require env("STRIPE_KEY")); theLAMPSON_*variables are Lampson's, exceptLAMPSON_CTX. The folder's.envis not loaded. - It runs only once approved:
lampson tools listshows each file and what it can touch, andlampson tools approve [name]approves that exact content. In the web UI, the herramientas button in the project header shows each file with its code and its ceiling, and approves it. - If anything changes, everything stops: if any
.synin the folder changes later, subfolders included (an agent rewrote it, you edited a_helper.syn), no tool runs until you approve it. The same if there is a link to another folder, a file that cannot be read, or the folder goes over the limit: 1 MB per file, 5 MB in total, 500.syn. - In development: with the SDK in local mode,
local: { approveTools: true }(Python:approve_tools=True) approves what is there at startup. Later changes, even the agent's, are approved by hand or by restarting. - Its
testblocks run withsynsema test .lampson/tools/order_status.syn, like any Synsema code. - Files starting with
_are helpers (use "./_common.syn"), not tools. AREADME.mdin the folder goes into the prompt of the agents that use them. - Works in both modes and without your app connected: the hub, scheduled tasks and teams can use them too.
Start in your language; move what touches money or customer data to .syn. What each option guarantees, and what it doesn't, is in Security, and a full example with four attacks is in Recipe: a support agent.
Who is writing: ctx§
The model writes the arguments, and a message can manipulate them ("I am customer 99"). So each tool gets the message's context separately: from, thread, agent, session, meta and channel, as your app sent them in ask. The model never sees or writes it. Use ctx to decide what each person may see:
await lampson.ask({ thread: "customer-42", from: { id: "c-42" }, meta: { store: "downtown" }, text: "where is my order?" });
In Python from is a keyword, so it goes as from_: lampson.ask(thread="customer-42", from_={"id": "c-42"}, text="…").
In JS and Python it is the second argument (ctx). In Synsema it arrives in env("LAMPSON_CTX"), only to the tools that declare require env("LAMPSON_CTX").
Permissions§
Being in your code (or in the folder) and in the agent's list is the authorization: they run in every mode. {"name": "cancel_order", "approve": "ask"} in the agent makes it ask for approval. A .syn tool that declares require spend(…) always asks.
Methods§
| JavaScript | Python | |
|---|---|---|
ask({agent, thread, text, …}) | ask(agent=, thread=, text=, …) | sends a message and waits for the answer: {agent, thread, session, text, steps, usage, stopped} |
stream({…}) | stream(…) | the agent's steps as they happen; the last one is done |
agents() | agents() | the available agents |
job(id) | job(id) | server mode: status of a message sent with callback |
close() | close() / with | local mode: stops the Lampson process |
Besides agent, thread and text, a message accepts session, images, meta, from (from_ in Python), callback (server mode) and mode ("strict" / "yolo", local mode). With callback, ask returns {job, status: "queued"} and the result arrives at your URL.
Errors: a turn that ends in an error, an invalid key (status 401), an agent outside the key (403) or a local process that died throw LampsonError.
In local mode, different conversations run in parallel and two messages on the same conversation are handled in order.
Verifying a callback§
If your Lampson has LAMPSON_WEBHOOK_SECRET, every callback arrives signed in X-Lampson-Signature. Pass it the exact body you received:
Lampson.verifySignature(rawBody, req.headers["x-lampson-signature"], process.env.LAMPSON_WEBHOOK_SECRET)
Lampson.verify_signature(raw_body, headers["X-Lampson-Signature"], secret)
Other languages§
Any language that can make a POST uses the HTTP API directly, and /v1/openapi.json lets you generate a client (Go, Java, C#, PHP…) with openapi-generator.