Setting Up a Project and Running Your First Pi Durable Conversation

By — 7+ years building ML / AI agent systems, founder of agent-engineering.dev & sandx.ai.
Published: Last updated:
pi durabledurable conversationin memory storageopenai compatible apiharness setup

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.

setting-up-pi-durable-project.png


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 -y

Make the project ESM (required by the packages):

npm pkg set type=module

Install the three core packages:

npm install @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord

Optional but recommended for TypeScript development:

npm install -D typescript @types/node tsx
npx tsc --init

In 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-here

Run it

npx tsx src/hello.ts

Or with native Node type stripping:

node --experimental-strip-types src/hello.ts

You 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.env

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

loadEnvFile()

Populates process.env from .env (Node.js 22+ built-in).

BACKGROUND_CONTEXT

A Chord Context that never cancels. Almost every async call on the harness takes a context (for cancellation and telemetry).

createModels() + setProvider()

The model layer from @earendil-works/pi-ai. Your custom provider must be registered here.

createRegistry()

An empty extension registry. Conversations can later select extensions (tools, system-prompt sections, hooks, etc.).

new MemoryStorage()

An in-memory storage backend. Perfect for tests and first experiments.

Harness.open(...)

Opens a Session over the storage. All mutations go through one atomic commit line. Nothing is shown until it is stored.

harness.root(...)

Returns the root conversation, creating it on first use with the agent options you pass.

root.submit(...)

Durably admits your input and returns a Submission. A built-in generation task calls the model.

submission.wait(...)

Resolves when the input has been answered (done) or has failed (unanswered).

harness.close(...)

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 process

After 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 SQLite

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