Setting Up a Project and Running Your First Pi Durable Conversation
What You Will Build
By the end of this article you will have:
A fresh Node.js project with the Pi Durable packages installed
A working script that opens a harness, creates the root conversation, submits a message, and prints the model's answer
An understanding of the core objects you just used (Harness, Conversation, Submission, Context)
A clear path to making that conversation survive process restarts (covered in the next article)
We deliberately start with in-memory storage. This lets you see the API with zero persistence complexity. Persistence, crash recovery, and resume() come next.

Prerequisites
Node.js 22.19 or newer
Pi Durable declares "node": ">=22.19.0". Check with node --version.
npm (or pnpm / yarn / bun)
An API key for an OpenAI-compatible endpoint (official OpenAI, a gateway, or any compatible service)
This article shows how to wire a third-party OpenAI-compatible API. The same pattern works for official OpenAI.
Create the Project
mkdir pi-durable-hello
cd pi-durable-hello
npm init -yMake the project ESM (required by the packages):
npm pkg set type=moduleInstall the three core packages:
npm install @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chordOptional but recommended for TypeScript development:
npm install -D typescript @types/node tsx
npx tsc --initIn tsconfig.json you will typically want:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src/**/*"]
}Your First Durable Conversation (In Memory)
Create src/hello.ts. This example uses a third-party OpenAI-compatible gateway (LLM Gateway) and loads the API key from a .env file. The same structure works for any OpenAI-compatible endpoint.
import { loadEnvFile } from "node:process";
loadEnvFile(); // loads .env into process.env
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import {
AssistantEntry,
createRegistry,
Harness,
MemoryStorage,
} from "@earendil-works/pi-durable";
import {
createModels,
createProvider,
envApiKeyAuth,
type Model,
} from "@earendil-works/pi-ai";
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
const context = BACKGROUND_CONTEXT;
const deepseekFlashV41Model: Model<"openai-completions"> = {
id: "deepseek/deepseek-v4.1-flash", // exact model id the API expects
name: "Deepseek V4.1 Flash",
api: "openai-completions",
provider: "llm-gateway",
baseUrl: "https://api.llm-gateway.io/openai/v1", // your base URL
reasoning: true,
input: ["text"],
cost: { input: 0.3, output: 1.2, cacheRead: 0.003, cacheWrite: 0 },
contextWindow: 1_024_000,
maxTokens: 8192,
};
const LLMProvider = createProvider({
id: "llm-gateway",
name: "LLM Gateway",
baseUrl: "https://api.llm-gateway.io/openai/v1",
auth: {
apiKey: envApiKeyAuth("LLM Gateway API key", ["LLM_GATEWAY_API_KEY"]), // reads LLM_GATEWAY_API_KEY env var
},
models: [deepseekFlashV41Model],
api: openAICompletionsApi(),
});
const models = createModels();
models.setProvider(LLMProvider);
const harness = await Harness.open(
new MemoryStorage(),
{ models, registry: createRegistry() },
context,
);
const root = await harness.root(context, {
agent: {
model: {
provider: "llm-gateway",
modelId: "deepseek/deepseek-v4.1-flash",
},
},
});
console.log("Submitting question…");
const submission = await root.submit(
{ type: "input", content: "What is the capital of France?" },
context,
);
const settled = await submission.wait(context);
if (settled.status === "done" && settled.type === "input") {
const answer = await root.commit(
(tx) => tx.entry(AssistantEntry, settled.answer),
context,
);
console.log("\nAnswer:");
console.log(answer?.model?.[0]);
} else {
console.error("Submission failed or was unanswered:", settled);
}
await harness.close(context);
console.log("Harness closed.");Environment file
Create a .env file in the project root (and add it to .gitignore):
LLM_GATEWAY_API_KEY=sk-your-key-hereRun it
npx tsx src/hello.tsOr with native Node type stripping:
node --experimental-strip-types src/hello.tsYou should see the model’s answer printed. The conversation existed only in memory and disappeared when the process exited.
Detailed Walkthrough of the Code
1. Loading the API key from .env
import { loadEnvFile } from "node:process";
loadEnvFile(); // loads .env into process.envNode.js 22+ has built-in .env support. Calling loadEnvFile() at the very top of the file populates process.env before any provider code runs. Existing environment variables are not overwritten. You can also use the CLI flag npx tsx --env-file=.env src/hello.ts instead of calling loadEnvFile().
2. Defining the model
const deepseekFlashV41Model: Model<"openai-completions"> = {
id: "deepseek/deepseek-v4.1-flash", // exact model id the API expects
name: "Deepseek V4.1 Flash",
api: "openai-completions",
provider: "llm-gateway", // must match the provider's id
baseUrl: "https://api.llm-gateway.io/openai/v1",
reasoning: true,
input: ["text"],
cost: { input: 0.3, output: 1.2, cacheRead: 0.003, cacheWrite: 0 },
contextWindow: 1_024_000,
maxTokens: 8192,
};id — the model identifier the remote API expects (exactly as it appears in the gateway’s model list).
provider — the string that links this model to the provider you will register. It must match the provider’s id.
api — tells pi-ai which protocol to use. For OpenAI-compatible endpoints this is "openai-completions".
baseUrl — the full base URL of the OpenAI-compatible endpoint.
cost, contextWindow, maxTokens, etc. — metadata used for budgeting, truncation, and UI. They do not affect the wire protocol.
3. Creating the provider
const LLMProvider = createProvider({
id: "llm-gateway",
name: "LLM Gateway",
baseUrl: "https://api.llm-gateway.io/openai/v1",
auth: {
apiKey: envApiKeyAuth("LLM Gateway API key", ["LLM_GATEWAY_API_KEY"]),
},
models: [deepseekFlashV41Model],
api: openAICompletionsApi(),
});id — the provider identifier. This is the string you will use later when selecting a model.
auth.apiKey — envApiKeyAuth looks up the listed environment variable(s) in process.env. The first one that is set is used. Stored credentials (if any) take precedence.
models — the catalog of models this provider offers.
api — the streaming/completion implementation. openAICompletionsApi() is the lazy-loaded OpenAI Chat Completions adapter.
4. Registering the provider
const models = createModels();
models.setProvider(LLMProvider);createModels() gives you an empty registry. setProvider() registers your provider under its id. This step is mandatory. If you forget it, later calls fail with “Provider is not configured: …”.
The same models instance must be passed to Harness.open. The harness looks up models only in the registry you give it.
5. Opening the harness and creating the root conversation
const harness = await Harness.open(
new MemoryStorage(),
{ models, registry: createRegistry() },
context,
);
const root = await harness.root(context, {
agent: {
model: {
provider: "llm-gateway",
modelId: "deepseek/deepseek-v4.1-flash",
},
},
});MemoryStorage — an in-memory backend. Perfect for the first experiment.
createRegistry() — an empty extension registry (tools, prompt sections, hooks, etc. come later).
harness.root(...) — returns the root conversation, creating it on first use with the agent options you pass.
The model object uses the provider id and model id you defined above. The harness stores only these two strings; it resolves the actual Model object from the registry at request time.
6. Submitting, waiting, and reading the answer
const submission = await root.submit(
{ type: "input", content: "What is the capital of France?" },
context,
);
const settled = await submission.wait(context);
if (settled.status === "done" && settled.type === "input") {
const answer = await root.commit(
(tx) => tx.entry(AssistantEntry, settled.answer),
context,
);
console.log(answer?.model?.[0]);
}submit durably admits the user input and returns a Submission. A built-in generation task calls the model.
wait resolves when the input has been answered (done) or has failed (unanswered).
The commit that retrieves the AssistantEntry is how you read the stored assistant message. Even with MemoryStorage, all mutations go through the same atomic commit protocol that later survives crashes.
7. Closing
await harness.close(context);Shuts the harness down cleanly. With MemoryStorage everything disappears when the process exits.
What Just Happened
Let’s walk through the pieces:
Piece | Role |
|---|---|
| Populates |
| A Chord |
| The model layer from |
| An empty extension registry. Conversations can later select extensions (tools, system-prompt sections, hooks, etc.). |
| An in-memory storage backend. Perfect for tests and first experiments. |
| Opens a Session over the storage. All mutations go through one atomic commit line. Nothing is shown until it is stored. |
| Returns the root conversation, creating it on first use with the agent options you pass. |
| Durably admits your input and returns a |
| Resolves when the input has been answered ( |
| Shuts the harness down cleanly. |
Key mental model from Article 1 still holds: the harness commits before it shows. Even with MemoryStorage, the commit protocol is the same one that will later survive crashes when you switch to SQLite or JSONL.
Making the Conversation Survive Restarts (Preview)
Replace the storage line with SQLite and call resume():
import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
const storage = await openNodeSqliteStorage("./session.sqlite");
const harness = await Harness.open(storage, { models, registry: createRegistry() }, context);
const root = await harness.root(context); // same root as last time if the file exists
harness.resume(); // continue any unfinished work from the previous processAfter this change:
Kill the process mid-answer and start it again → the conversation continues.
The SQLite file (session.sqlite) contains the full transcript, task checkpoints, and documents.
We will explore crash recovery, resume(), replay safety, and the task graph in depth in the next article.
Common Gotchas
Node version — Anything older than 22.19 will fail or behave unpredictably.
ESM — The packages are ESM-only. Your package.json must contain "type": "module".
API key — envApiKeyAuth looks for the environment variable(s) you listed (here LLM_GATEWAY_API_KEY). Make sure loadEnvFile() runs (or you use --env-file) before the provider is used. Missing key → “Provider is not configured”.
Provider not registered — You must call models.setProvider(...) on the exact models instance passed to Harness.open. Forgetting this produces “Provider is not configured: …”.
Matching IDs — The model’s provider field, the provider’s id, and the provider string in harness.root must all be identical.
Model ID — Use the exact model id the remote API expects (including any / path segments).
Experimental API — Pin the versions you install. The API can change between releases.
One process owns the storage — Do not open the same SQLite file from two processes at once.
Project Layout (Suggested)
pi-durable-hello/
├── package.json
├── tsconfig.json
├── .env # LLM_GATEWAY_API_KEY=... (gitignored)
├── src/
│ └── hello.ts
└── session.sqlite # appears once you switch to SQLiteYou can keep growing this same project through the rest of the series.
What Comes Next
Article 3 — Persistence: SQLite, Crash Recovery, and resume()
We will:
Kill a process in the middle of a model turn or tool call
Reopen the same SQLite file
Watch the harness resume from the last checkpoint
Understand replay-safe vs interrupted tools
Use requestId for exactly-once submissions
Official Resources
GitHub repository (packages/durable)
Examples: packages/durable/test/examples
Previous article: Introduction to Pi Durable
Next article: Persistence — SQLite, Crash Recovery, and resume()
You now have a running durable conversation that talks to a third-party OpenAI-compatible API. The rest of the series builds on this foundation.
Get new posts by Nelson Lin
Subscribe to receive email notifications when a new article is published.