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