Examples / Chat / durable-chat

Durable Chat

From 04_chat/durable-chat

A multi-provider LLM chat app on Durable Actors: every conversation is an actor, and every reply is a durable pi run that survives a crash mid-stream.

Elysia backend, pi-durable for the model runs, and a Vite + React frontend. Pick Anthropic, OpenAI, Gemini or Grok per message, stop or retry a reply, fork a conversation, and open the same chat in two tabs to watch a reply stream into both.

 Browser (client/web) ── HTTP /api/* ──► Elysia (client/src/backend.ts) ── typed RPC ──► actors
        │                                                                                │
        ├── WebSocket ──► ChatIndex("main")   @Persisted @Emittable chats → live sidebar  │
        └── WebSocket ──► Chat(<chatId>)      messages · model · reply runs ◄────────────┘
                                │  @Interleave: join mid-stream, stop, rename while a reply streams
                                │  @Resource DurablePiAgent: a pi harness in the actor's own SQLite
                                ▼
                          pi-ai → anthropic · openai · google · xai

How it works

  • Browsers never call actors over RPC. They ask Elysia for a WebSocket grant, then connect straight to the actor.
  • Each reply is a durable pi run. DurablePiAgent (actor/src/pi/agent.ts) keeps a pi harness in the actor's SQLite through ActorStorage (actor/src/pi/storage.ts), an append-only commit log. The Chat actor streams pi's partial answer to every socket as deltas.
  • Crash recovery. If the process dies mid-reply, the harness resumes the run when the actor next starts, and an alarm keeps waking the actor until pi has no unfinished tasks. The reply then finishes as if nothing happened.
  • Stop is an RPC, not a socket message. Messages from one socket are handled in order, so a stop sent on the reply's own socket would wait for the reply to finish. The browser calls POST /api/chats/:id/stop instead, and @Interleave lets Chat.stop() run mid-reply.
  • Retry and fork reuse pi's own messages. Retry rewrites pi's context to the history before the last question and asks it again. Fork copies the messages and the matching pi history into a new Chat actor.

Run it

You need Bun 1.4.2+ and the Terse CLI. Add at least one provider key to actor/.env (copy actor/.env.example): ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY or XAI_API_KEY. The model picker only lists providers that have a key.

From this repository:

bun run example durable-chat

As a new project:

terse init my-chat --template durable-chat
cd my-chat/actor && terse actor dev          # terminal 1
cd my-chat/client && terse actor generate && bun start   # terminal 2

Open the URL Vite prints (http://localhost:5173 by default). Edit actor/src/models.ts to change the model list; IDs are provider/model strings from pi-ai's catalog.

Layout

Path What it is
actor/src/actor.ts Chat and ChatIndex actors
actor/src/pi/ DurablePiAgent resource and its SQLite storage
actor/src/models.ts Model list and key-gated availability
client/src/backend.ts Elysia API: create, stop, rename, pin, archive, fork, export, delete, socket grants
client/web/ Vite + React UI
client/generated/ Typed client from terse actor generate (don't edit)

Deploy

cd actor
terse secrets import .env   # provider keys for the cloud actors
terse deploy

Then set TERSE_ACTOR_URL and TERSE_API_KEY in client/.env (see client/.env.example) and run terse actor generate in client/ against the deployed actors.

There is no web search: pi has no built-in provider search. Add one as a tool in the harness registry (createRegistry() in actor/src/pi/agent.ts) backed by the search API of your choice.

actor/src/actor.ts

actor/src/actor.tsL1–339
import type { Message } from "@earendil-works/pi-ai"
import { Actor, Emittable, Ephemeral, Interleave, Persisted, Resource, unchecked, type ActorSocket, type Unchecked } from "terse-sdk/actor"

import { availableModels, isAvailable, type ModelOption } from "./models.js"
import { DurablePiAgent, type Answer } from "./pi/agent.js"

export class Chat extends Actor<null, ChatIncoming, ChatOutgoing> {
    @Persisted title = "New chat"
    @Persisted model = ""
    @Persisted messages: ChatMessage[] = []
    @Persisted createdAt = 0
    @Persisted updatedAt = 0
    @Persisted deleted = false
    @Ephemeral #run: Run | null = null
    // A TypeScript `private`, not `#agent`: the runtime finds resources by field name, which can't see # fields.
    @Resource private readonly agent = new DurablePiAgent(this.db, this.alarm)

    async create(model: string): Promise<void> {
        if (this.createdAt) return
        this.model = model
        this.createdAt = this.updatedAt = Date.now()
    }

    /** Starts a forked chat from another chat's messages and the matching pi history. */
    async seed(chat: { title: string; model: string; messages: ChatMessage[]; history: Unchecked<Message[]> }): Promise<void> {
        this.title = chat.title
        this.model = chat.model
        this.messages = chat.messages
        this.createdAt = this.updatedAt = Date.now()
        await this.agent.seed(chat.history as unknown as Message[])
        await this.#syncIndex()
    }

    async onConnect(socket: ActorSocket<null, ChatOutgoing>): Promise<void> {
        if (this.deleted) return socket.reject(4004, "chat deleted")
        socket.send({ type: "snapshot", chat: this.#view() })
        await this.#resumeStalled()
    }

    @Interleave
    async onMessage(socket: ActorSocket<null, ChatOutgoing>, message: ChatIncoming): Promise<void> {
        if (this.deleted) return
        switch (message.type) {
            case "send":
                return this.#send(socket, message.text, message.model)
            case "retry":
                return this.#retry(socket, message.model)
        }
    }

    /** Fires while pi has unfinished tasks. A reply a crash cut off finishes here once pi resumes its run. */
    @Interleave
    async onAlarm(): Promise<void> {
        await this.agent.onAlarm()
        const stalled = this.#stalledReply()
        if (stalled) await this.#follow(stalled)
    }

    /**
     * Stops the streaming reply. This is an RPC, not a socket message: messages from one socket are handled
     * in order, so a stop sent on the reply's own socket would wait until the reply had finished.
     */
    async stop(): Promise<void> {
        if (!this.#run) return
        this.#run.abort.abort()
        await this.agent.abort()
    }

    async rename(title: string): Promise<void> {
        this.title = title.trim().slice(0, 120) || this.title
        this.#broadcastSnapshot()
        await this.#syncIndex()
    }

    /** Copies messages up to and including `uptoMessageId` (or all of them), with their pi history, into chat `newId`. */
    async fork(newId: string, uptoMessageId?: string): Promise<void> {
        const end = this.messages.findIndex(message => message.id === uptoMessageId) + 1 || this.messages.length
        const messages = this.messages.slice(0, end).filter(message => message.status !== "streaming")
        const history = historyBefore(await this.agent.history(), userCount(messages) + 1)
        await Chat.get(newId).seed({ title: `${this.title} (fork)`, model: this.model, messages, history: unchecked(history) })
    }

    async view(): Promise<ChatView> {
        return this.#view()
    }

    async destroy(): Promise<void> {
        this.deleted = true
        await this.stop()
        this.alarm.delete()
        await ChatIndex.get(INDEX_ID).remove(this.id)
        for (const socket of await this.getConnections()) socket.close(4004, "chat deleted")
    }

    async #send(socket: ActorSocket<null, ChatOutgoing>, text: string, model: string) {
        const prompt = text.trim()
        if (!prompt || !this.#canStart(socket, model)) return
        if (this.messages.length === 0) this.title = titleFrom(prompt)
        this.#append(newMessage({ role: "user", content: prompt }))
        await this.#generate(model, prompt)
    }

    /** Asks the last question again on pi's history from before it, so the old answer leaves the model's context. */
    async #retry(socket: ActorSocket<null, ChatOutgoing>, model: string) {
        const question = [...this.messages].reverse().find(message => message.role === "user")
        if (this.messages.at(-1)?.role !== "assistant" || !question || !this.#canStart(socket, model)) return
        const history = historyBefore(await this.agent.history(), userCount(this.messages))
        this.messages = this.messages.slice(0, -1)
        this.#broadcastSnapshot()
        await this.#generate(model, question.content, history)
    }

    #canStart(socket: ActorSocket<null, ChatOutgoing>, model: string): boolean {
        const problem = this.#run || this.#stalledReply() ? "Still replying. Stop it or wait." :
            !isAvailable(model) ? `No API key is configured for ${model}.` : null
        if (problem) socket.send({ type: "error", message: problem })
        return problem === null
    }

    /** Runs one reply. `#run` is set before the first await, so only one reply streams at a time. */
    async #generate(model: string, text: string, history?: Message[]) {
        const reply = newMessage({ role: "assistant", content: "", status: "streaming", model })
        const run = this.#start(reply)
        this.model = model
        this.#append(reply)
        await this.#drive(run, show => this.agent.reply({ text, model, history }, show), "failed")
    }

    /** Finishes a reply left streaming by a crash: pi resumed its run, so wait for that run's answer. */
    async #follow(reply: ChatMessage) {
        await this.#drive(this.#start(reply), show => this.agent.follow(show), "interrupted")
    }

    async #drive(run: Run, answer: (show: (text: string) => void) => Promise<Answer>, unanswered: MessageStatus) {
        await this.#syncIndex()
        try {
            this.#settle(run, await answer(text => this.#show(run, text)), unanswered)
        } catch (error) {
            this.#finish(run, run.abort.signal.aborted ? "stopped" : unanswered, { error: errorText(error) })
        } finally {
            this.#run = null
            await this.#syncIndex()
        }
    }

    #start(reply: ChatMessage): Run {
        const run: Run = { messageId: reply.id, text: reply.content, savedAt: Date.now(), abort: new AbortController() }
        this.#run = run
        return run
    }

    #settle(run: Run, answer: Answer, unanswered: MessageStatus) {
        if (answer.text) this.#show(run, answer.text)
        if (run.abort.signal.aborted) return this.#finish(run, "stopped")
        if (answer.answered) return this.#finish(run, "done")
        this.#finish(run, unanswered, unanswered === "failed" ? { error: answer.error ?? "The model did not answer." } : {})
    }

    /** pi reports the whole partial each time: send what grew, or the whole text when a retry attempt restarted it. */
    #show(run: Run, text: string) {
        if (text === run.text) return
        if (text.startsWith(run.text)) {
            this.broadcast({ type: "delta", id: run.messageId, text: text.slice(run.text.length) })
        } else {
            const message = this.#patch(run.messageId, { content: text })
            if (message) this.broadcast({ type: "message", message })
        }
        run.text = text
        this.#savePartial(run)
    }

    /** Saves the partial reply at most once per SAVE_PARTIAL_MS, for the transcript; pi keeps its own durable copy. */
    #savePartial(run: Run) {
        if (Date.now() - run.savedAt < SAVE_PARTIAL_MS) return
        run.savedAt = Date.now()
        this.#patch(run.messageId, { content: run.text })
    }

    #finish(run: Run, status: MessageStatus, extra: { error?: string } = {}) {
        // Socket messages must be strict JSON, so only set optional fields that have values (never undefined).
        const changes: Partial<ChatMessage> = { content: run.text, status }
        if (extra.error) changes.error = extra.error
        const message = this.#patch(run.messageId, changes)
        if (message) this.broadcast({ type: "message", message })
    }

    /** If pi is still running the cut-off reply, the alarm follows it; otherwise it already settled and finishes now. */
    async #resumeStalled() {
        const stalled = this.#stalledReply()
        if (!stalled) return
        if (await this.agent.busy()) this.alarm.set(Date.now())
        else await this.#follow(stalled)
    }

    #stalledReply(): ChatMessage | undefined {
        const last = this.messages.at(-1)
        return !this.#run && last?.role === "assistant" && last.status === "streaming" ? last : undefined
    }

    #view(): ChatView {
        const run = this.#run
        const messages = run
            ? this.messages.map(message => (message.id === run.messageId ? { ...message, content: run.text } : message))
            : this.messages
        return { id: this.id, title: this.title, model: this.model, updatedAt: this.updatedAt, messages }
    }

    #append(message: ChatMessage) {
        this.messages = [...this.messages, message]
        this.updatedAt = Date.now()
        this.broadcast({ type: "message", message })
    }

    #patch(id: string, changes: Partial<ChatMessage>): ChatMessage | undefined {
        let updated: ChatMessage | undefined
        this.messages = this.messages.map(message => (message.id === id ? (updated = { ...message, ...changes }) : message))
        this.updatedAt = Date.now()
        return updated
    }

    #broadcastSnapshot() {
        this.broadcast({ type: "snapshot", chat: this.#view() })
    }

    async #syncIndex() {
        if (this.deleted || this.messages.length === 0) return
        await ChatIndex.get(INDEX_ID).sync({
            id: this.id,
            title: this.title,
            model: this.model,
            streaming: this.#run !== null,
            createdAt: this.createdAt,
            updatedAt: this.updatedAt,
        })
    }
}

/** The sidebar. Browsers connect over WebSocket and receive `chats` as live state. */
export class ChatIndex extends Actor<null, null, null> {
    @Persisted @Emittable chats: ChatSummary[] = []

    /** Models whose provider key is set in this project's environment. */
    async models(): Promise<ModelOption[]> {
        return availableModels()
    }

    async list(): Promise<ChatSummary[]> {
        return this.chats
    }

    /** Called by a Chat when its title, model or run state changes. Keeps the pin and archive flags. */
    async sync(chat: ChatSync): Promise<void> {
        const current = this.chats.find(summary => summary.id === chat.id)
        const next: ChatSummary = { ...chat, pinned: current?.pinned ?? false, archived: current?.archived ?? false }
        this.chats = byRecent([next, ...this.chats.filter(summary => summary.id !== chat.id)])
    }

    async setFlags(id: string, flags: { pinned?: boolean; archived?: boolean }): Promise<void> {
        this.chats = this.chats.map(summary => (summary.id === id ? { ...summary, ...flags } : summary))
    }

    async remove(id: string): Promise<void> {
        this.chats = this.chats.filter(summary => summary.id !== id)
    }
}

/** pi's history before its `n`th user message (all of it when there are fewer). */
function historyBefore(history: Message[], n: number): Message[] {
    let users = 0
    const end = history.findIndex(message => message.role === "user" && ++users === n)
    return end === -1 ? history : history.slice(0, end)
}

function userCount(messages: ChatMessage[]): number {
    return messages.filter(message => message.role === "user").length
}

function titleFrom(prompt: string): string {
    const line = prompt.split("\n")[0]!.replace(/\s+/g, " ").trim()
    return line.length > 60 ? `${line.slice(0, 57)}…` : line
}

function newMessage(fields: Pick<ChatMessage, "role" | "content"> & Partial<ChatMessage>): ChatMessage {
    return { id: crypto.randomUUID(), status: "done", createdAt: Date.now(), ...fields }
}

function errorText(error: unknown): string {
    return (error instanceof Error ? error.message : String(error)).slice(0, 300)
}

function byRecent(chats: ChatSummary[]): ChatSummary[] {
    return [...chats].sort((a, b) => b.updatedAt - a.updatedAt)
}

const INDEX_ID = "main"
const SAVE_PARTIAL_MS = 1_000

export type { ModelOption, Provider } from "./models.js"

export type ChatIncoming =
    | { type: "send"; text: string; model: string }
    | { type: "retry"; model: string }

export type ChatOutgoing =
    | { type: "snapshot"; chat: ChatView }
    | { type: "message"; message: ChatMessage }
    | { type: "delta"; id: string; text: string }
    | { type: "error"; message: string }

export type ChatView = Pick<ChatSummary, "id" | "title" | "model" | "updatedAt"> & {
    messages: ChatMessage[]
}

export type ChatMessage = {
    id: string
    role: "user" | "assistant"
    content: string
    status: MessageStatus
    createdAt: number
    model?: string
    error?: string
}

export type MessageStatus = "streaming" | "done" | "stopped" | "interrupted" | "failed"

export type ChatSummary = {
    id: string
    title: string
    model: string
    streaming: boolean
    pinned: boolean
    archived: boolean
    createdAt: number
    updatedAt: number
}

export type ChatSync = Omit<ChatSummary, "pinned" | "archived">

type Run = { messageId: string; text: string; savedAt: number; abort: AbortController }