openstation

Core Concepts

Sessions and turns

Manage conversation continuity, concurrency, and resets.

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

View Markdown source on GitHub ↗