Skip to content

A chat that survives a reload

This guide builds a persistent chat conversation. One action supports multiple providers, the page retains the model's structured interaction objects, and a reload continues the same conversation.

The session design is provider-agnostic: Ollama, Claude, and Gemini use the same interface, configured via headers.

Before you start

The demo on this page talks to the hosted backend at wss://a11.to/ws/demoserver, which runs an Ollama beside itself — so the default (Ollama, glm-4.7-flash, base URL http://127.0.0.1:11434) answers without a key. The process serving the action resolves the base URL, so 127.0.0.1 refers to that backend host. Claude and Gemini need a key.

To run the backend yourself instead:

pip install a11-kit
python -m a11.demos.web_demos_server   # ws://127.0.0.1:9010/a11-demos

A page loaded over HTTPS may refuse a plaintext ws:// socket even to localhost (Chrome allows it, Firefox does not), so give a local backend the --certificate / --private-key flags and a trusted certificate — mkcert makes one — if the browser blocks it.

Either address goes in the demo's first field. https:// and wss:// name the same endpoint here. Browsers connect directly because A11's WebSocket server supports HTTP/1.1 and HTTP/2. Install the TypeScript package with npm install a11@npm:@curiositystack/a11.

Try it

Ask something, then reload the page: the conversation is still there, and the next answer is given in its context. New starts a fresh one without dropping the socket. The right-hand pane is the thoughts port — a model that thinks before it speaks shows its working there, on a port of its own, while text_output streams the answer.

Chat

The page is js/demo/chat_sessions.ts over js/demo/demo_support.ts, and the backend is a11/demos/web_demos_server.py. An a11 gateway run serves the same three actions, so a page can point at one of those instead.

1. One action, every provider

interact_with_llm uses one action schema for all supported model providers. The provider, model, key, and base URL are headers, so one registration serves them and the caller selects the backend:

from a11.sdk.llm import LlmHeaders

LlmHeaders.PROVIDER  # x-a11-llm-provider   claude | gemini | ollama
LlmHeaders.MODEL  # x-a11-llm-model
LlmHeaders.API_KEY  # x-a11-llm-api-key
LlmHeaders.BASE_URL  # x-a11-llm-base-url

In the browser they are the same names, set on the call before it is dispatched:

const call = need(Action.create(INTERACT_WITH_LLM_SCHEMA, {
    session,
    stream,
    nodeMap: session.getNodeMap(),
}));
need(call.setHeader(LlmHeaders.PROVIDER, 'ollama'));
need(call.setHeader(LlmHeaders.MODEL, 'glm-5.3-flash:cloud'));
need(call.setHeader(LlmHeaders.BASE_URL, 'https://ollama.com'));
need(await call.call());

Select a hosted model by setting the provider, model, and API key. The ports, reading code, and conversation format remain unchanged.

The action's ports are the same whoever answers: interactions, tools and config in; text_output, thoughts, event_stream and new_interactions out. A page reads the visible answer off text_output and never has to parse a provider's event stream.

2. The conversation is a list of interactions

A turn's history is not a transcript rebuilt from text. It is the list of a11.sdk.llm.Interaction objects the provider produced, including tool calls and results. The next turn sends that structured history back to the model:

const interactions = need(await call.getInput('interactions'));
for (const interaction of history) need(await interactions.put(interaction));
need(await interactions.finalize(question));

The page uses the first interaction's ID as the conversation ID. The backend therefore does not need to return a separate session handle.

3. The backend records what it answers

On the server the action is wrapped in one that stores the turn as it goes, a11.gateway.conversation_actions.interact_with_llm_and_persist, and a second action reads the recording back:

from a11.gateway import conversation_actions, conversations

store = conversations.ConversationStore("/var/lib/a11/conversations")
conversation_actions.install(registry, store)
# registers: interact_with_llm (recording), get_conversation,
#            get_conversations

The store is SQLite: one AsyncNode per conversation, backed by a SQLite chunk store, plus a small table that indexes them for the list. It needs no server, survives a restart, and await store.record(interactions) is idempotent — the page replays its whole history every turn, and only what is new is appended, by interaction id.

Not every model call is a conversation

The demo server also registers the same action, unrecorded, as ask_model. A step inside a composition is not a chat turn: recorded, each of the deep-research agent's model calls would arrive in this guide's conversation list as a conversation of its own.

4. Reloading is one call

get_conversation streams one conversation's interactions back, given its id. The page declares the schema by hand — it is the backend's, mirrored:

const GET_CONVERSATION_SCHEMA = new ActionSchema({
    name: 'get_conversation',
    inputs: {
        id: new ActionPortSchema({
            name: 'id', type: 'text/plain', unary: true, required: true,
        }),
    },
    outputs: {
        interactions: new ActionPortSchema({
            name: 'interactions', type: 'application/json', required: true,
        }),
    },
});

What comes back is re-parsed on the way in, and that matters more than it looks:

const next = need(await node.next({timeoutMs: 30_000, expectedTag: INTERACTION_TAG}));
restored.push(need(parseInteraction(next)));

parseInteraction brands the value with its serialization tag, which is what lets it go back out to the backend as an a11.sdk.Interaction on the next turn with the a11.sdk.Interaction type expected by the interactions port. The same tag table is what makes this work across languages at all — see js/src/serial_tags.ts and a11/data/serial_tags.py.

Because the restored interactions become the history, the next turn continues the same conversation and lands on the same conversation node on the backend: its id is the first interaction's id, replayed unchanged.

5. Keep the id in the URL

The last piece of "survives a reload" is not A11 at all:

const url = new URL(window.location.href);
url.searchParams.set('conversation', this.conversationId!);
window.history.replaceState(null, '', url);

On load, the page reopens whatever conversation the URL names.