Embed the engine
Run the Pistis engine inside your own server with the packages.
The Pistis packages run without the web app. Use them to put the agent engine inside your own server, with your own sandboxes and sign-in. Install the ones you need from npm:
npm i @pistishq/sdkOne package, a subpath for each part (Packages):
| Package | What it is |
|---|---|
@pistishq/sdk | The Sessions contract, its HTTP mapping and client, and the plugin contract (@pistishq/sdk/node: the handler on node:http) |
@pistishq/sdk/engine | The engine: openEngine |
@pistishq/sdk/hands | relaySandboxes and the BoxProvider contract; @pistishq/sdk/hands/relay is the relay (the service sandboxes connect to) as a Node server. pistisd, the program in the sandbox that runs commands, is in the sandbox image |
@pistishq/sdk/sandbox-docker, /sandbox-kubernetes, /sandbox-daytona | Sandbox providers |
@pistishq/sdk/scm, /scm/github, /scm/gitlab | Code hosts and the sourceControl plugin |
@pistishq/sdk/auth, /auth/oidc, /auth/github | Sign-in methods and the authorizer contract |
@pistishq/sdk/fleet | Several servers on one Postgres |
@pistishq/sdk/slack | createSlackSurface, your own Slack bot on a server |
Wire it up
Sandboxes run in Docker and dial out to a relay container. They need the pistis/relay:dev and pistis/pistisd:dev images: npx @pistishq/cli dev pulls them, and in a clone pnpm images builds them.
import { randomBytes } from "node:crypto";
import { dirCheckpoints, model, openEngine, Principal, sqliteStore } from "@pistishq/sdk/engine";
import { relaySandboxes } from "@pistishq/sdk/hands";
import { dockerKind, ensureRelayContainer } from "@pistishq/sdk/sandbox-docker";
import { sourceControl } from "@pistishq/sdk/scm";
import { githubApp } from "@pistishq/sdk/scm/github";
// Keep the relay's key, so a restarted relay accepts the sandboxes it already has.
const adminKey = process.env.PISTIS_RELAY_KEY ?? randomBytes(24).toString("hex");
const relay = await ensureRelayContainer({ name: "pistis-relay", adminKey, stateVolume: "pistis-relay" });
const boxes = dockerKind.create({ namespace: "prod", relayContainer: relay.name, allow: ["npm"] });
const sandboxes = relaySandboxes({ relay: { url: relay.url, adminKey }, handsUrl: relay.handsUrl, boxes });
const github = githubApp.create({ appId: "12345", privateKey: process.env.GITHUB_APP_KEY ?? "" });
const engine = await openEngine({
store: sqliteStore(".pistis/brain.sqlite"), // the directory must exist; or postgresStore(url)
model: await model("anthropic/<model-id>"), // the key comes from ANTHROPIC_API_KEY
checkpoints: { store: dirCheckpoints(".pistis/checkpoints") },
sandboxes,
// Clones on the server into /workspace/repo; adds the publish tool (push + pull request).
plugins: [sourceControl({ resolve: async (s) => {
const repo = await github.repo("acme/api");
return repo && { host: github, repo, requestedBy: { principal: s.principal, name: String(s.principal) } };
} })],
});
const s = await engine.create({ requestId: "ticket-123", principal: Principal("alice"), agent: { instructions: "You fix failing tests." } });
const { submissionId } = await engine.send(s.sessionId, "fix the failing test", "msg-1");
const settled = await engine.settled(s.sessionId, submissionId);
if (settled.status !== "done") throw new Error(`the turn ended unanswered: ${settled.reason}`); // e.g. model_errorWith no key for any provider, engine.create throws and names the variable to set (such as ANTHROPIC_API_KEY). A key the provider refuses ends the turn unanswered with model_error, and the assistant entry carries the provider's message in error.
Add your own tools
A plugin's tools run on your server, are recorded like built-in tools, and can wait for approval by name.
import { defineTool } from "@pistishq/sdk/engine";
const deploy = defineTool({
name: "deploy_preview",
description: "Deploy this session's branch to a preview environment.",
parameters: { type: "object", properties: { branch: { type: "string" } }, required: ["branch"] },
run: async ({ branch }, { sessionId, principal }) => ({ text: await previews.deploy(String(branch), principal) }),
});
await openEngine({ /* … */ plugins: [{ name: "previews", tools: [deploy] }], approvals: { tools: ["deploy_preview"] } });Serve it over HTTP
npm i @pistishq/cliimport { createHttpSurface } from "@pistishq/cli";
const token = process.env.PISTIS_TOKEN; // at least 16 characters
if (!token) throw new Error("PISTIS_TOKEN required");
createHttpSurface({ sessions: engine, token }).listen(7777, "127.0.0.1");With token, every session belongs to one built-in user, http. Pass users: { [token]: name } to give each user their own sessions. On another web-standard runtime, sessionsHandler(asActor(engine, principal)) from @pistishq/sdk serves the same routes once your sign-in knows who is calling.
Talk to it from anywhere
import { httpSessions } from "@pistishq/sdk";
const pistis = httpSessions("https://pistis.example.com", { token: process.env.PISTIS_TOKEN });
const s = await pistis.create({ requestId: "ticket-123" });
await pistis.send(s.sessionId, "fix the failing test", "msg-1");
for await (const e of pistis.follow(s.sessionId)) {
if (e.kind === "approval" && e.decided === undefined) await pistis.decide(s.sessionId, e.key, true);
else if (e.kind !== "approval") console.log(e.seq, e.kind, e.text);
}The client implements the same Sessions contract as the engine, so code written against one runs against the other. It reconnects a dropped stream from the last entry it saw. memorySessions() stands in for the engine while you build a front end.
Engine options
| Option | Meaning |
|---|---|
store | sqliteStore(path) or postgresStore(url). One engine per database. |
model | await model("provider/model-id") |
sandboxes | A sandbox provider, such as relaySandboxes({ relay, handsUrl, boxes }) |
prepare(workspace, session) | Your setup step, run once per workspace and not again over restored files |
approvals | { tools: string[]; auto?: boolean }. A name that is no tool stops the start. auto allows Auto mode. |
plugins | [{ name, tools?, instructions? }] |
mcp | { servers, resolve? } (MCP servers) |
skills | Where skills come from. A session keeps the versions it started with. |
instructions | The start of every session's system prompt |
checkpoints | { store, maxBytes?, exclude? } |
lifecycle | { pauseAfter?, stopAfter?, deleteAfter? } in milliseconds. deleteAfter needs checkpoints. |
readyTimeoutMs | How long a new sandbox may take to connect. Default 30 seconds. |
Sessions methods
| Method | What it does |
|---|---|
create({ requestId, principal, meta?, agent? }) | A new session owned by principal. The same principal and requestId return the same session. |
get(id), list({ principal?, meta?, ended?, after?, limit? }) | Look sessions up |
send(id, input, key) | Saves the message, then returns { submissionId }. The same key is accepted once. |
settled(id, submissionId) | Resolves when the turn is over: done, or unanswered with a reason |
transcript(id, after?) | Entries after a position |
follow(id, { after?, signal? }) | Waiting approvals, then entries, then live ones until signal aborts |
approvals(id), decide(id, key, approved, actor) | Read and answer approvals. Only the owner may decide. |
sandbox(id) | Where the sandbox is, without touching it |
deliver(key, deliveryId, payload) | Continue the sessions waiting on key, once per deliveryId |
cancel(id) | Stop the turn and drop waiting messages (on a flow run's session, the whole run) |
end(id) | Refuse new messages, and delete the sandbox and its saved files. The conversation is kept. |
asActor(engine, principal) from @pistishq/sdk limits a caller to their own sessions.