Lampson

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, build answers: Lampson's coding agent, with all its tools.
  • lampsonTools: false (lampson_tools in Python, local mode only): no tool of Lampson's own exists in that process — no bash, read, write or fetch — 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 with onWarning (on_warning), or null (None) to silence it; the list stays in lampson.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 be https, except localhost.
  • 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 signed call_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, uvicorn with 1 worker and blocking code): if that worker is waiting on an ask, 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 runsin your appLampson runs it
with your app down (scheduled tasks, teams)noyes
what it can touchwhatever your app canonly 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 task with 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 task is denied. env(…) only with exact names (require env("STRIPE_KEY")); the LAMPSON_* variables are Lampson's, except LAMPSON_CTX. The folder's .env is not loaded.
  • It runs only once approved: lampson tools list shows each file and what it can touch, and lampson 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 .syn in 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 test blocks run with synsema test .lampson/tools/order_status.syn, like any Synsema code.
  • Files starting with _ are helpers (use "./_common.syn"), not tools. A README.md in 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§

JavaScriptPython
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() / withlocal 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.

View as Markdown