A turn is one message in, one reply out. A session is what makes the next turn remember the last one.
A turn
Synchronous, start to finish: the connector hands the message to the Bridge, the Bridge resolves who's asking and what's permitted, the TurnRunner takes a permit, the executor runs Claude Code in the workspace, and the reply goes back out the same connector. Nothing is queued and there's no job to poll.
If the turn fails, the user gets copy rather than a stack trace — "Something went wrong processing that — please try again." The real detail is in the process output.
A session
Continuity is per conversation, keyed by whatever the connector considers one conversation:
| Connector | Conversation key | So a conversation is… |
|---|---|---|
| REPL | repl:<uuid> |
one dev session, reset by /new |
| Slack | slack:<channel>:<thread-ts> |
a thread; top-level messages key on their own timestamp |
| Telegram | telegram:<chat-id>[:<thread-id>] |
a chat, or a forum topic within it |
email:<root-message-id> |
a reply chain, resolved from References / In-Reply-To |
The key maps to a Claude session id in a SQLite store under var/sessions.db. On the next turn
for that key, the executor resumes that session, so the agent has the earlier exchange without
the platform re-sending any of it. This is the "pointers, not payloads" rule in its most literal
form: OpenStation stores an id, not a transcript.
Every key carries its connector's prefix, which is what lets an unprompted delivery route back
to the connector that minted it. Slack's gained one on 2026-07-30 (it was
<channel>:<thread-ts>), so refs stored before then no longer match and those threads start
fresh. Two connections of one type still share a prefix, so a key cannot tell them apart —
see internal/roadmap.md under "Known gaps to v1".
When a session goes stale
If Claude rejects a resumed session id — expired, or the underlying session is gone — the turn is retried once with no session, starting a clean conversation rather than failing. The user sees a reply that has lost the earlier context, not an error.
The session ref is read and written inside the per-conversation lock, so a fast follow-up can't race persistence and be treated as a fresh conversation.
openstation serve drops refs for conversations idle more than 7 days, at boot and daily after
that. It is fixed, not configurable: the cost of dropping one is a fresh session, and resuming a
week-old thread nobody remembers is worse. openstation dev does not sweep — its store is a single
process's own.
One turn at a time per conversation
While a turn is running, another message for that same conversation is refused:
Still working on your previous message — please wait.
It isn't buffered or queued — the user has to send it again. Different conversations run concurrently, subject to the global cap.
The concurrency cap
Three turns at once by default. One permit is reserved for interactive traffic, so background work — triggers, scheduled jobs — can never consume the last slot and starve a live conversation.
Over the cap a turn waits for a permit, first-come-first-served, and then runs — a burst is slower, not lossy. Up to 32 turns may be waiting; only a caller arriving once that queue is full is turned away:
I'm at capacity right now — please try again in a minute.
That is the global cap queueing. A second message in one conversation is still refused outright, as above — including while that conversation's turn is waiting for a permit.
Time and cost limits
There is no token or cost budget anywhere. Nothing caps what a turn may spend, and nothing reports what it did spend. Design docs that describe budgets are describing intent.
Wall-clock limits depend on which executor the agent declares:
executor: |
Per-turn limit |
|---|---|
claude-cli — what the scaffold picks |
15 minutes, then the child is killed |
claude-sdk |
none; the turn runs until Claude returns |
So a scaffolded workspace is bounded in time but not in spend, and switching to claude-sdk
removes even the time bound. For a real ceiling, put one outside OpenStation: a process
supervisor, a container limit, or provider-side limits on the key you give it.
Tracked in internal/roadmap.md under "Known gaps to v1".
Resetting a conversation
Declare a reset text and a conversation can be cleared in place:
bridge:
reset: "/reset"
Sending it drops that conversation's session ref, so the next message starts a new Claude
session. It spends no turn, and nothing in the workspace changes — a ref is a pointer, and
dropping it forgets the conversation, not the work. Without the declaration the same text is an
ordinary message. Full behaviour, including why it runs after policy and why a busy conversation
is refused: bridge.reset.
Otherwise, start a new thread (Slack), a new chat (Telegram), or a new mail chain (Email) — a new conversation is a new key, and a new key is a new session.
In dev, /new is a different reset. The REPL handles it itself: it mints a fresh
conversation key and never reaches the Bridge, so it starts a new session by changing which
conversation you are in rather than by dropping a ref. A workspace declaring reset: "/new"
therefore gets nothing — the REPL swallows the text first.