pi-durable × discordTechnical design · v0.1
Technical design · October 2026

A durable
Discord agent

Thread-scoped conversations, committed turns, and a sandboxed project workspace. Built with pi-durable 1.3 for execution state and pi for model access.

Writers1 / SQLite file
Conversation1 / thread
Idempotencymessage.id
Streamingnone
Discordgateway, threads, identity, rate limits
→
Harnessconversation, tasks, SQLite, policy
→
ExecutionEnvproject mount, read/write/edit/bash
Architecture study01
pi-durable × discord00 — Scope
The job

What the system must do

  1. Accept work in a Discord thread. A message becomes an input; the thread is the boundary of the conversation.
  2. Run a model turn with tools. The model can read, write, edit, and execute commands inside a project directory.
  3. Survive a process crash. A turn that has started must finish after a restart, without asking the user to repeat it.
  4. Keep threads independent. Each thread has its own transcript, model choice, and running task graph.
  5. Stop for human approval. A push, deploy, or destructive command must be approvable from Discord.
  6. Enforce a workspace policy. Who can use a workspace, which tools are available, and what paths they can touch.
The hard part is not the model call. It is making the turn resumable and making tool execution safe for untrusted users.
The boundary

What it deliberately does not do

Streaming

No token-by-token edits. Discord allows roughly 5 message edits per 5 seconds per channel; a final edit is cheaper and more reliable.

Tenancy

No multi-tenant SaaS in v1. One bot process, one SQLite file, one writer. Isolation is a deployment choice.

Permissions

No custom Discord permission model. Discord roles are inputs; the workspace membership table is the authority.

Sandbox

No sandbox by default. NodeExecutionEnv runs as the bot user; a container or VM is a separate decision.

Secrets

No secret manager. Model keys live in the harness process; the sandbox must not inherit them.

The design goal is a single-user or trusted-guild bot first, with the seams needed to move execution into a container or a Durable Object later.

Scope, non-goals, and the actual risk02
pi-durable × discord01 — Runtime
Runtime

pi-durable is a library, not a service

You host the process. Harness.open() binds one storage and one agent runtime. There is no daemon, no HTTP server, and no built-in Discord integration.

Harness owns one storage and the task scheduler. It runs generation tasks, tool tasks, compactions, and any custom tasks you install through the registry.

Conversation is an append-only transcript with its own agent settings. submit() admits an input and returns a Submission. wait() resolves when the answer is committed — or when the input is unanswered, with a reason.

viewState() is a read-only projection of committed state. It is not a source of truth and can be dropped at any time.

Storage is single-writer. One process owns a SQLite file. There is no cross-process lock, so two replicas against the same file will corrupt the assumptions, not just the data.
src/open.tspi-durable 1.3.0
import { Harness } from "@earendil-works/pi-durable";
import { openNodeSqliteStorage } from
  "@earendil-works/pi-durable/storage/sqlite/node";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";

const harness = await Harness.open(
  await openNodeSqliteStorage("./data/agent.sqlite"),
  { models, registry, env },
  BACKGROUND_CONTEXT,
);

// one conversation per Discord thread
const conversation = await harness.createConversation({
  ownership: { kind: "ownerless" },
  agent: {
    model: { provider: "deepseek", modelId: "deepseek-flash" },
    cwd: "/workspace/project",
  },
}, BACKGROUND_CONTEXT);

const submission = await conversation.submit({
  type: "input",
  content: "run the tests and fix the failure",
  requestId: message.id,
}, BACKGROUND_CONTEXT);

const settled = await submission.wait(BACKGROUND_CONTEXT);
// settled.status: "done" | "unanswered"
Harness.open

One storage, one scheduler, one registry of tools and tasks.

Conversation

One thread's transcript and agent settings.

Submission

Durable input with an idempotency key and a terminal status.

resume()

Schedules tasks left pending by the previous process.

No daemon. No hidden state. One storage per process.03
pi-durable × discord02 — Data model
Data model

Four records, one commit line

Everything the harness needs to resume is in one of these records. There is no separate in-memory source of truth; the SQLite commit is the boundary.

RecordWhat it isExamplesMutability
Entry One immutable item in the active transcript. The model context is derived from the newest reset or compaction head. pi.user · pi.assistant · pi.tool-result · pi.system · pi.reset Append-only. Never updated. A fork creates a new conversation that inherits entries up to a chosen entry.
Document Typed JSON state stored next to the transcript and changed inside a commit. pi.agent · pi.live · pi.inbox · pi.usage · pi.provider Mutable, but only through a commit. A crash cannot leave a half-written document.
Task A durable state machine with a checkpoint after each step. Tasks can own child tasks and conversations. pi.generation · pi.tool · compaction · subagent Every step commits; a restart continues from the last checkpoint.
Submission An admitted input or passive write with a lifecycle and an optional idempotency key. queued → placed → done · unanswered Terminal once answered. The same requestId returns the existing submission.
Why entries are append-only. A retry can append a new assistant entry, but it cannot rewrite history. Compaction writes a summary entry with a new head; the old entries remain readable for audit and forks.
Why documents exist. Some state is not a message: the selected model, the live stream, the queued inbox, token spend, and the provider session ID. Documents keep that state on the same commit line as the transcript.
Schema tables are prefixed pi_ when sharing a SQLite file04
pi-durable × discord03 — Lifecycle
One turn

Seven commits from message to answer

  1. Gateway receives the message. The adapter resolves the guild, channel, thread, author, and workspace. message.id becomes the requestId.
  2. Commit the input. A pi.user entry is appended and a pi.generation task is created in the same commit.
  3. Call the model. Streamed text is written to pi.live.generation at a throttled interval (default 100 ms), not to the transcript yet.
  4. Run tools. Every tool call becomes a pi.tool task. The intent and arguments are committed before execute() runs.
  5. Commit tool results. Each result becomes a pi.tool-result entry. The next generation sees it in context.
  6. Commit the answer. The final pi.assistant entry and the submission settlement are written together.
  7. Render. The gateway reads viewState() and edits the anchor message once. The Discord view is a projection, not state.
A failed tool does not fail the turn. The model gets an error result and can choose another action. Only provider failures and aborted runs settle the submission as unanswered.
Fig. 1 — commit sequenceone turn
01inputpi.user
02generatepi.generation
03streampi.live
04toolpi.tool
05resultpi.tool-result
06answerpi.assistant
07 renderedit one Discord message from viewState()
The moving dot follows the commit order. Steps 04 and 05 repeat for every tool call in the round.
Partial writes

progress.partialIntervalMs defaults to 100 ms; a crash loses at most that window of streamed text.

Tool intent

Arguments are fixed and committed before execution. A crash mid-tool cannot produce a different call on replay.

Settlement

The final assistant entry and submission.status = done are one commit, so a visible answer implies a settled submission.

Commit before display; render from committed state05
pi-durable × discord04 — Failure model
Failure model

What survives a crash

Crash pointAlready durableWhat the next process does
Before submit()nothingThe message is lost. Discord does not replay gateway events; the backfill cursor cannot recover a message it never saw.
After pi.userthe input + generation taskresume() starts the generation. The user does not resend.
Mid-generationthe partial in pi.liveThe generation retries. The partial may be committed as an aborted assistant entry; it is not shown as the answer.
After tool intent, before resultthe call and its argumentsThe tool reruns only if declared replay: "safe". Otherwise the model gets an interrupted result and decides what to do.
After tool resultthe result entryThe next generation continues with the committed result. The tool does not run again.
After the answeranswer + settled submissionThe gateway re-renders from viewState(). No duplicate answer is generated for the same requestId.
Replay is opt-in. A tool marked replay: "safe" must be idempotent; a crash can rerun it. A tool without it is never rerun. The model sees an interrupted result, which is the honest outcome for a side effect you cannot prove safe.
Resume is not magic. harness.resume() schedules tasks that are still live. It does not replay a Discord message that never reached the bot, and it does not recover a provider request that was never committed as a task.
Fig. 2 — crash and resumeat any committed step
commitpi.user
→
generatetask
→
toolintent
→
resultentry
→
answersettled
✕ crash
reopen → resume()
At-least-once input, at-most-once side effects when declared safe06
pi-durable × discord05 — Mapping
Discord adapter

Discord objects to durable IDs

The adapter is a translation layer. It turns Discord identity into a workspace and a conversation, and it turns committed state back into messages.

DiscordDurableWhy
thread.idconversation_idOne thread owns one transcript. The mapping is a host table, not a Discord field.
message.idrequestIdDuplicate gateway delivery or a retry returns the existing submission.
parent channelworkspace_idRepo path, instructions, tool policy, and budget live on the workspace.
last_message_idbackfill cursorOn boot, fetch messages after this ID and replay them.
anchor messagerender targetThe one message the bot edits when the answer is committed.
Deleting a thread is not deleting data. The host row remains. The conversation can be reattached from another thread with /resume <id>; only the Discord view is gone.
Fig. 3 — mapping rowhost-owned table
Discord threadthread 1184…
→
discord_threadchannel_id · conversation_idlast_message_id · anchor_message_id
→
Conversationid 42 · entries · docs · tasks
Backfill on boot:
SELECT channel_id, last_message_id FROM discord_thread WHERE last_message_id IS NOT NULL;
For each row: messages.fetch({ after: last_message_id, limit: 100 }), then submit in ascending snowflake order.
The cursor advances only after submit() commits the input. A crash between the two replays the same requestId.
Thread deleted

Row stays; view is lost. /resume reattaches.

Thread archived

Row stays; sending a message unarchives it.

New thread

Create a conversation on first message; store the row before submitting.

The conversation is durable; the thread is a view07
pi-durable × discord06 — Interface
Interface

The thread, annotated

# fix-auth / platform
⌕ ☰ ⚙
THREADfix-auth created from #platform
M
maya10:42
tests are failing on main. can you take a look at the auth refresh path?
π
piBOT10:42
Running the suite in #platform and checking the refresh middleware.
Workspace run · committed
4 passed · 1 failed
src/auth/refresh.ts — expiry dropped on rotation
3 tools · 42s · conversation 42
π
piBOT10:44
Patch is in, tests pass. Push needs approval.
Approval required · git push
Push fix/auth-refresh to origin? The approval is a saved task; a restart will not cancel it.
Approve pushDeny
Message #fix-auth
Illustration, not a client screenshot. Names and paths are examples.
Annotations

What each element maps to

Thread

thread.id → host row → conversation_id = 42. One thread, one transcript.

Message

message.id → requestId. The same message cannot start two runs.

Run card

Written from pi.live and the committed tool results. The model's raw output stays in the transcript.

Anchor

The bot edits one message. The ID is stored in anchor_message_id; a restart edits the same one.

Approval

A durable child task with a committed decision. Buttons write the decision; a timeout denies.

No token streaming. The bot posts once when the answer commits. Tool-level cards are optional and are written from committed task state, not from raw model deltas.
One thread · one anchor · one approval task08
pi-durable × discord07 — Project access
Execution boundary

How the agent sees your repository

The harness does not read files itself. Every read, write, edit, and command goes through an ExecutionEnv. That is the seam you choose.

PatternCommands runHost exposureUse when
Bind mountcontainer / hostthe mounted projectsingle user, fastest loop
SSHFSinside the VMfiles under the mountcommands must stay sandboxed
RemoteExecutionEnvon the remote hostthe daemon's userkeys must stay outside the sandbox
Git worktreeper threada branch checkoutparallel threads on one repo
cwd is not a jail. It is the starting directory for the environment. bash can cd /, read ~/.ssh, and run env. If that matters, use an OS sandbox, not a path check.
Fig. 4 — split executionrecommended for public use
Harness processSQLite · model keys · policy · conversation
↓ RemoteExecutionEnv
Sandbox / VMpi-env daemon · project mounted at /work
readfile API
writefile API
bashprocess API
All three cross the same boundary. There is no separate host-side file tool that bypasses the environment.
With RemoteExecutionEnv, the harness can keep model keys and SQLite local while every tool operation runs inside the sandbox.
Path mapping

cwd must be the path inside the environment. The host path and container path may differ.

Worktrees

git worktree add per thread gives parallel runs separate branches and no shared index.

Mount only the repo; sandbox the environment, not the command string09
pi-durable × discord08 — Policy
Policy

Allow, ask, deny — in that order

The policy is host code. pi-durable gives you modelTools to hide tools and a beforeTool hook to block or rewrite a call before it is committed.

  1. Hide what the model cannot use. AgentChange.modelTools filters the tools offered to the model. callers decides whether a tool is model-callable, tool-callable, or both.
  2. Match the path. Resolve symlinks before matching. A deny on *.env must also fire for a symlink that points at .env.
  3. Parse the command. Regex is not enough: bash -c, eval, xargs, and find -exec hide the real command. Use a real parser, or ask/deny when parsing fails.
  4. Decide and record. allow runs; ask creates a durable approval task; deny returns an error result and writes an audit row.
Default deny is the safe default. A command the parser cannot fully resolve should prompt, not pass. An unknown tool should be hidden, not allowed.
policy.jsonhost config
{
  "tool": { "*": "allow", "bash": "ask" },
  "bash": {
    "git status*": "allow",
    "npm test*": "allow",
    "git push*": "ask",
    "rm -rf *": "deny"
  },
  "path": {
    "*": "allow",
    "*.env": "deny",
    "~/.ssh/*": "deny"
  },
  "external_directory": "ask"
}
policy.tspi-durable hook
const Policy = defineExtension({
  name: "policy",
  hooks: [hook(ToolTask, {
    beforeTool: async (call, api) => {
      const decision = evaluate(call, policyFor(api.conversationId));
      if (decision === "deny") return { block: decision.reason };
      if (decision === "ask") {
        // create a durable approval task; do not await a UI here
        return { block: "needs approval: use /approve <id>" };
      }
    },
  })],
});
A hook waiting on a Discord click is not crash-safe. The durable version is a child task with a committed decision.
Hooks block; tasks approve10
pi-durable × discord09 — Approvals
Approval

An approval is a durable task

The model asks to run a dangerous tool. The call is committed, but execution waits for a human decision that is itself committed. If the process dies, the wait survives.

  1. Tool call arrives. Name, arguments, and conversation are known. The policy returns ask.
  2. Create an approval task. It owns the wait and stores the request ID, tool name, arguments hash, and expiry.
  3. Post the card. The adapter sends the approval embed with buttons and stores the message ID.
  4. Commit the decision. The button handler writes approved or denied to the task document, with the Discord user ID and timestamp.
  5. Resume or block. The task continues and calls executeTool(), or returns a blocked result.
  6. Timeout denies. A timeout writes denied with reason timeout; the model gets an error result.
Why not await a button in beforeTool? The hook is not a task. If the process dies while waiting, the wait is gone and the tool task may be orphaned or rerun without a decision.
Fig. 5 — approval looprestart-safe
Approval required · git pushtask 7a4…
Push fix/auth-refresh to origin? Approver: @maya. Expires in 15:00.
Approve Deny
requesttool · args hash
→
decisionapproved · user · ts
→
executeor blocked
On restart: the approval task is still live, the decision document is intact, and the tool either continues or stays blocked.
Timeout and missing approver are terminal denied decisions, not silent passes.
Who can approve

Check the workspace role at click time; do not trust the embed's author.

What is stored

Tool name, arguments hash, requester, approver, decision, timestamp, expiry.

Decision committed before execution11
pi-durable × discord10 — Sandbox
Isolation

What the sandbox does and does not protect

The policy decides whether a tool may run. The sandbox decides what the tool can reach after it runs. They are different layers and neither replaces the other.

RiskMitigation
Reading ~/.ssh or cloud credentialsOS-level sandbox; cwd and path checks are not enough
Inheriting the bot token or model keysScrub shellEnv; never mount the harness environment
Exfiltrating the repo over the networkNo egress, or an allowlisted proxy; deny fetch-and-exec
Deleting files outside the projectMount only the project; per-thread worktree
Prompt injection in repo contentTool policy plus a sandbox; the prompt is not a security boundary
NodeExecutionEnv runs as the bot user. It is a convenience, not a jail. Use a container, bubblewrap, Gondolin, or RemoteExecutionEnv for untrusted users.
Fig. 6 — boundarypacket blocked at the wall
Harnesskeys · SQLite · policy
Sandboxproject · commands
no keys, no token
readproject only
writeproject only
bashno network
Strong isolation comes from the OS or VM, not from the tool name or the working directory.
env.tssplit execution
import { RemoteExecutionEnv, sshConnection }
  from "@earendil-works/pi-env";

const { connection } = sshConnection({
  host: "sandbox",
  knownHostsFile: "/data/ssh/known_hosts",
  hostKeyAlias: "pi-sandbox",
});

const env = new RemoteExecutionEnv({
  connection,
  id: "sandbox:project",
  cwd: "/work/project",
});
Policy says yes or no; the sandbox limits the blast radius12
pi-durable × discord11 — Deployment
Deployment

Three shapes, with different failure domains

ShapeHarness + storageToolsIsolationConstraint
A · single Node process One process, one SQLite file, one writer. openNodeSqliteStorage(). NodeExecutionEnv on the host or a mounted container. Process-level. Every workspace shares the same bot user unless you add a sandbox. One replica. A missing workspace_id in a query leaks across guilds.
B · Durable Object per workspace One DO per workspace; openDurableObjectSqliteStorage(ctx.storage). Container or remote env; the DO itself is not a shell. Per-workspace object. No shared SQLite and no shared memory. Discord gateway is a WebSocket; a Worker-only bot is slash-command-only unless you hold it in a DO.
C · split execution Harness on a host with keys and SQLite; tools on a remote host or VM. RemoteExecutionEnv over SSH; all file and process operations cross the boundary. Strongest for untrusted users: keys never enter the execution host. Path mapping and a daemon to deploy, update, and monitor.
Start with A. It is the shortest path to a working agent: one process, one file, full tool support. Add a container before exposing it to a public guild.
Move to C before multi-user. The split is not about scaling; it is about keeping the model key and the bot token out of the environment that runs untrusted commands.
Fig. 7 — failure domainswhat dies with what
Abot dies → all workspaces stop; one SQLite file must be recovered
Bone DO dies → one workspace stops; other objects keep running
Csandbox dies → tools fail; the harness and transcript survive
One owner per storage; the failure domain follows the boundary13
pi-durable × discord12 — Plan
Build order

Phases and exit criteria

PhaseDeliverableExit criterion
P0Durable turn, Discord adapter, SQLite, backfill cursorkill -9 mid-turn, restart: the same conversation finishes and the anchor is edited once.
P1Workspace registry, thread-per-conversation mapping, /resumeDelete a thread, reattach its conversation from another thread; transcript is intact.
P2Policy engine: allow/ask/deny, path canonicalization, bash parsingA symlink to .env is denied; an unparseable bash -c prompts or fails closed.
P3Container or remote execution environment; scrubbed secretsFrom inside bash: curl fails, ~/.ssh is unreadable, env has no bot token.
P4Durable approval task with Discord buttons and timeoutRestart while an approval is pending; the same task still accepts the decision or times out to deny.
P5Quotas, token budgets, metrics, backups, retentionA per-user daily budget refuses a new run before the model call, not after.
No phase depends on streaming. The only Discord output that matters is the final edit. The first milestone is the crash test in P0; everything else is hardening.
Ship the failure model first14
pi-durable × discord13 — Decisions
Before P1

Decisions to lock now

DecisionOptionsRecommendationWhy
Tenant modelSingle user; trusted guild; public multi-tenantSingle user firstOne writer and one SQLite file are enough; isolation can move to a DO or container later.
Project accessBind mount; SSHFS; RemoteExecutionEnv; git syncMount the repo onlyFastest loop; the sandbox still protects the host outside the mount.
ApprovalBlock in v1; durable ask task nowBlock in v1; add ask in P4A correct durable wait needs a task, a decision document, and a timeout — not a hook.
StorageSQLite; Durable Object SQLite; Postgres adapterSQLiteOne process, one file, transactional commits. Postgres is an ecosystem adapter, not a requirement.
Discord ingressGateway WebSocket; HTTP interactions onlyGateway + slash commandsOrdinary messages need the gateway; interactions alone are slash-command-only.
P0durable turn + crash test
→
P1threads + workspaces
→
P2–P4policy · sandbox · approvals

The first milestone is a crash test, not a feature list: start a tool call, kill the process, restart, and the same conversation finishes with no duplicate side effect.