Chat
subs chat is an agent session in your terminal. It is a channel, like
Slack: a line you type becomes a client message, and the
event stream becomes text.
subs chatassistant
model claude-sonnet-5
llm claude (anthropic)
session 01a02417-7d46-7441-8090-23b20d0f980f
> what is the capital of Portugal?
The capital of Portugal is Lisbon.
> how far is it from Madrid?
About 500 km.
>subs run sends one message and exits, so a second message needs --session
and a second process. Chat holds the session, so the next question can depend
on the last one.
Where the turn runs
The file decides, as it does for every other command. A file that names no
[remote] describes an engine here, so chat starts one in the process and the
session lands in this machine's database. A file that names one describes a
deployment, so chat streams the turn from there.
subs chat # the file decides
subs chat --url http://localhost:8080 # a deployment, for this chat--url also points at a subs serve you are running, which is how to chat
with the engine that answers your Slack workspace.
See CLI.
Which agent
Chat drives the agent [run].agent names — the same question subs run
answers, so one file serves both. --agent <id> picks another for one chat.
[run]
agent = "assistant"The session
A chat with no --session opens a new one and prints its id. Ctrl-D ends
the chat and prints how to pick it back up.
continue this session with:
subs chat --session 01a02417-7d46-7441-8090-23b20d0f980fThe session is durable, so it outlives the process: subs sessions list shows
it, subs run --session adds a turn to it, and a Slack thread on the same
engine is the same kind of thing. See Conversations.
↑ walks the lines you have typed, in this chat and in earlier ones. The
history is beside your credentials, in
$XDG_CONFIG_HOME/substructure/chat_history.
A line ending in \ is not finished, so Enter opens the next one and the
message carries the newline. Alt-Enter and Ctrl-J do the same.
> summarise this: \
the first point \
the second pointShift-Enter is not one of them, and cannot be: a terminal sends the same
byte for Enter and Shift-Enter, so nothing downstream can tell them apart.
Terminals that let you map a key can send Alt-Enter's bytes — ESC then
CR — for it:
kitty map shift+enter send_text all \x1b\r
wezterm { key = "Enter", mods = "SHIFT", action = act.SendString("\x1b\r") }
alacritty { key = "Return", mods = "Shift", chars = "\u001b\r" }
iTerm2 Keys → Key Bindings → Shift-Enter → Send Hex Code → 0x1b 0x0dWhile a turn runs
A turn is quiet between the model call and its first token, and again while a tool runs. A line below the transcript names the step in progress and counts the time it takes.
⠙ fetch_url (7s)It names one call, counts a batch of them (2 tools), and counts the attempts
when the engine tries the same call again. The line is drawn on stderr and
erased before anything else is written, so subs run piped into another
program still writes only the turn.
A call is written once, when it is answered: the status line is what says it is running, so the transcript keeps one line per call.
● get_current_time (180ms)
2026-08-22T09:50:33.010Z
↻ fetch_url {"url":"https://example.com"} (attempt 1, 2.1s)
503 Service Unavailable
● fetch_url {"url":"https://example.com"} (attempt 2, 1.4s)
{"status":"ok"}A call on a connection is named by the server and the tool, as each of them says it is called:
● deepwiki Ask a question {"q":"login page"} (1.2s)● answered, ✗ failed, ↻ failed and will be tried again. A call that took
less than a moment shows no time. A result that is only text reads as that
text.
A result longer than the screen is cut short, and the rest is counted:
… +182 linesWhat the answer looks like
The answer is markdown, and it reads as markdown: headings, code, bold,
lists, quotes, and links carry their styling rather than their markers. A line
is styled once it is whole and a fenced block once it closes, so the transcript
is written once and never rewritten.
Styling is bold, dim, italic, and underline, and nothing else. No colour is set, so the transcript reads on a light background as well as a dark one and there is no theme to keep right.
A model told to answer in another dialect is read as CommonMark all the same —
Slack's *bold* is CommonMark's italic, so an agent whose system prompt asks
for Slack mrkdwn reads differently here than it does in Slack.
Interrupt prompts
A turn that stops to ask something renders the question where you are, and your answer resumes it. No id is typed.
An interrupt whose metadata.options names answers renders them as a picker —
the same list Slack draws as buttons. ↑/↓ pick, Enter answers, and the
turn carries on in the same chat.
? Run `issues__delete_issue`?
```
{
"id": "7"
}
``` ›
❯ Run it
DeclineAn interrupt that offers no options takes typed text, delivered as the resolution payload whole.
A question waits where a Slack prompt's buttons wait: in the session, not in
the process. Ctrl-C at a question ends the chat and leaves it parked, and a
chat that opens a parked session — --session, after any exit — asks the
question first, before reading a line.
What chat resolves is stamped channel: "cli", so the session records which
channel answered and which option was picked, the same way Slack's does. See
Interrupts.
What it does not do
The first cut renders a turn and answers what it parks on. It has no slash commands, and it cannot send a message while a turn is running.
Nothing here stops a turn the engine has started. Ctrl-C during a turn stops
watching it and prints how to pick the session back up; the turn runs to its
end, and the next chat on that session reads what it wrote.
A client tool ends the turn with nothing to settle it, so chat says so and stops rather than offering an input it cannot send:
⧗ get_weather is waiting on a client-side result, which this chat cannot settle.Use subs run --input '{"type":"tool.result",…}' for those. See
Client tools.
Related
no-code-chat: a chat agent that is one file.- Slack: the same session, in a thread.
- CLI: every command and flag.