Pistis

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/sdk

One package, a subpath for each part (Packages):

PackageWhat it is
@pistishq/sdkThe Sessions contract, its HTTP mapping and client, and the plugin contract (@pistishq/sdk/node: the handler on node:http)
@pistishq/sdk/engineThe engine: openEngine
@pistishq/sdk/handsrelaySandboxes 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-daytonaSandbox providers
@pistishq/sdk/scm, /scm/github, /scm/gitlabCode hosts and the sourceControl plugin
@pistishq/sdk/auth, /auth/oidc, /auth/githubSign-in methods and the authorizer contract
@pistishq/sdk/fleetSeveral servers on one Postgres
@pistishq/sdk/slackcreateSlackSurface, 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_error

With 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/cli
import { 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

OptionMeaning
storesqliteStore(path) or postgresStore(url). One engine per database.
modelawait model("provider/model-id")
sandboxesA 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)
skillsWhere skills come from. A session keeps the versions it started with.
instructionsThe start of every session's system prompt
checkpoints{ store, maxBytes?, exclude? }
lifecycle{ pauseAfter?, stopAfter?, deleteAfter? } in milliseconds. deleteAfter needs checkpoints.
readyTimeoutMsHow long a new sandbox may take to connect. Default 30 seconds.

Sessions methods

MethodWhat 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.

On this page