---
title: SDK (JavaScript and Python)
description: Talk to Lampson's agents from your code — npm i lampson or pip install lampson — against a running Lampson (server) or with Lampson inside your program (local).
---
# SDK

```sh
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](/en/docs/api), with a URL and an [API key](/en/docs/api#api-keys). 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`](/en/docs/headless#stdio-one-open-process-local-mode). 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

```js
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

```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:

```js
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
```

```python
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](/en/docs/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`.

```js
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: {...} })
```

```python
@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:

```js
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())
```

```python
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 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 `require`s |

### In Synsema: with guarantees

One file per tool in your project's `.lampson/tools/` (another folder inside the project with `local: { tools: "./tools" }`):

```synsema
-- .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 `require`s 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](/en/docs/security), and a full example with four attacks is in [Recipe: a support agent](/en/docs/support).

### 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:

```js
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:

```js
Lampson.verifySignature(rawBody, req.headers["x-lampson-signature"], process.env.LAMPSON_WEBHOOK_SECRET)
```

```python
Lampson.verify_signature(raw_body, headers["X-Lampson-Signature"], secret)
```

## Other languages

Any language that can make a POST uses the [HTTP API](/en/docs/api) directly, and `/v1/openapi.json` lets you generate a client (Go, Java, C#, PHP…) with `openapi-generator`.
