selan.ai

Docs · Connect

Connect

A launch you can answer from your phone. The session opens a private Slack channel, says what it is doing there, and takes your replies as if you had typed them at the terminal. For how the pieces fit together, start at the docs.

What it is

selan claude connect is the same launch as selan claude, with one difference: the session is reachable. It opens a private channel in your workspace, posts there when it starts, shows what it is working on, and asks its questions there as well as in the terminal. Answering in Slack answers the session.

Your machine never holds a Slack credential. The workspace token lives at the relay, and the only thing the laptop carries is its own Selan session. Revoking that session cuts the channel with it, and a laptop that never held a bot token cannot leak one.

Selan writes down nothing you say or the model says, though Slack keeps its own copy of the channel under your workspace's retention settings. What Selan persists is which workspace you paired, which channel a piece of work resolved to, and that a session started, so a relaunch can be told from a reconnect. No text, no paths, no filenames.

Starting one

The first run asks you to connect a workspace and prints a link to approve it. After that every launch is one word longer than usual.

selan claude connect

One workspace per account. selan claude disconnect forgets it, so another can be connected.

The channel a session gets

By default the channel is named after the checkout the launch came from: the repository, the worktree when there is one, and the branch when it is not the default. Launching again from the same place returns to the same channel, so the conversation about a piece of work stays in one place across days and machines.

A session keeps the room it settled into. A websocket is a request and dies on the hour, so a connected session reconnects all day; checking out a branch in the middle of one does not move the conversation. The room is decided once, when the session first connects, and every reconnect is answered with the same one.

Renaming the channel in Slack is safe. Routing is by channel id in both directions. Rename it, move it, call it whatever reads best.

Naming a connection

A checkout is the right name for work on a repository and the wrong one for an agent. A long-running assistant is the same thing whichever directory it happens to be standing in, and it needs to say so.

selan claude connect --cid agent-x

That names the connection rather than the checkout. The channel becomes #selan-agent-agent-x, and the same name comes back to the same conversation from any directory and any branch. You go to the agent by name instead of retracing the directory it started in.

One name, one live connection. Starting a second session on a name another is holding is refused rather than joined: two agents answering in one channel read as one confused conversation, and Slack does not say which machine wrote which line. Once the first ends, the name is free again.

Names are lowercase letters, digits and dashes, the way Slack channels are. A name that is not usable is refused at the terminal rather than quietly reshaped into one that would stop matching later.

Running more than one

Give each agent its own name and each gets its own channel. A message typed in one channel reaches that session and no other, because the channel is the routing key rather than the account. Two agents on one machine never read each other's instructions or report each other's work.

The names are yours: --cid releases, --cid oncall, --cid inbox-triage. The sidebar becomes a list of what you have running.

Declaring the ones you keep

A name you type every day is a name worth writing down once. Put the launches a repository has in .selanrc.json at its root, and they come with the checkout rather than living in somebody's shell history.

{
  "agents": {
    "reviews": {
      "agent": "code-review",
      "cid": "reviews"
    },
    "oncall": {
      "agent": "incident-triage",
      "cid": "oncall",
      "ask": false
    }
  }
}
selan run reviews
# → selan claude connect --cid reviews --agent code-review

selan run oncall
# → selan claude connect --cid oncall --agent incident-triage \
#     --disallowedTools AskUserQuestion

selan run
# lists what the file declares, and launches nothing

The file says which agent and under which name. .claude/agents/ says what that agent is, so there is no model field here: the agent definition owns that, and a second place to write it is a second place for it to disagree.

A cid is what asks for a channel, so an entry without one is an ordinary launch. Anything you type after the name is kept and kept last, so selan run reviews --model openrouter:… works and the file stays a set of names rather than a cage.

A field it does not recognise is refused rather than ignored. A name that quietly is not what it reads like is worse than one that will not start, so a misspelt key says so at the terminal.

"ask": false is for the agent you want to decide rather than consult you. It silences both ways a session can stop and ask: Claude Code's own question, and the one that reaches your phone. The second is not disallowed by name but never offered, because the name a plugin's tools are given is Claude Code's to decide, and a flag that missed it would leave the file claiming something untrue. An agent that cannot ask says in its final answer what it could not decide.

A name whose agent does not exist is refused before launching. Both places Claude Code reads one from are checked, .claude/agents/ beside the file and the same under your home directory, matched on the frontmatter's name: the way Claude Code matches it. Without that you would get Claude Code's list of its own built-in agents, which says nothing about the file that named yours.

seland run reviews is the same declared launch with permission checks off. The name expands the same way and then the second binary adds its flag, so you get that agent, in its own channel, without the prompts. There is no field for this in the file on purpose. Unlike ask, it widens what the session may do rather than narrowing how it reports, and that is a decision to make at the terminal rather than one a committed file makes for everyone.

Shaping what starts

--cid is the only argument connect adds. Everything else reaches Claude Code as typed, except a --model carrying a provider prefix, which Selan routes to your company's key. So a connected session can be shaped the same way any other launch is.

selan claude connect --cid reviews --agent code-review --model claude-opus-5

--agent names a definition in .claude/agents/, the same files subagents come from, so its model, its tools and its instructions become that session's. A narrow agent on a fixed name is an assistant that does one job and always answers in the same channel.

seland connect --cid reviews is the same launch with permission checks off, for a session you intend to leave running unattended. connect is read there as it is under selan, so the channel and the name still happen. It is a separate word rather than a flag so that turning it on is deliberate. See seland.

Reaching one from outside

A named session can also be reached without Slack and without a terminal: one HTTP request, authenticated by the Selan session token you already have.

curl https://connect.selan.ai/api/v1/messages \
  -H "Authorization: Bearer $SELAN_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"cid":"agent-x","text":"CI is red on main"}'

The message appears in that session's channel and the session acts on it, exactly as if you had typed it there. This is how a CI job, a cron line or another service hands an agent something to do.

A name nobody is holding is refused with 409 rather than opening a channel. The endpoint reaches an agent, and a message posted into a room nobody is listening to reads as delivered while going nowhere.

Asking one a question

A message tells an agent to do something. A question waits for its answer, and takes it in a shape you decide.

curl https://connect.selan.ai/api/v1/questions \
  -H "Authorization: Bearer $SELAN_TOKEN" \
  -H 'content-type: application/json' \
  -d '{
        "cid": "agent-x",
        "question": "Which migrations are pending, and is any of them destructive?",
        "schema": {
          "type": "object",
          "properties": {
            "pending":     { "type": "array", "items": { "type": "string" } },
            "destructive": { "type": "boolean" }
          },
          "required": ["pending", "destructive"]
        },
        "timeoutMs": 120000
      }'

The request stays open until the agent answers or timeoutMs passes, and what comes back at 200 is valid against the schema you sent. An expired deadline answers 504; a name nobody is holding answers 409, and an agent that could not match your schema answers 422 with what it said instead.

The shape is enforced rather than requested. The schema travels to the agent's own machine, where the answer is checked against it before it is sent, and a mismatch goes back to the model as a tool error inside the same turn. It reads what was wrong and corrects.

What is enforced is the shape, not the content. It is still a model's answer, and the same question put to a session that has been doing other work may come back different. If you need the same answer every time, a session carrying its own context is the wrong place to ask.

One question per session at a time. A session runs one turn, so a second would be answered out of the same turn as the first with nothing to say which answer belonged to which.

Which agents are up

The same token lists what you have running, and what each one is doing.

curl https://connect.selan.ai/api/v1/sessions \
  -H "Authorization: Bearer $SELAN_TOKEN"
{
  "sessions": [
    { "cid": "agent-x", "roomId": "C0A1B2C3D4", "label": "demo · main", "state": "working",
      "lastActiveAt": "2026-09-19T10:14:02.000Z", "lastMessageAt": "2026-09-19T10:13:58.000Z" }
  ]
}

state is working while the agent is narrating and idle when it is not. lastActiveAt is when it last did something itself; lastMessageAt is when something last reached it. A recent message against an older action is exactly "somebody wrote to it and it has not moved since".

GET /api/v1/sessions/agent-x answers for one name, and answers 200 either way: asking whether an agent is up is a question, and "no" is an answer to it rather than an error.

Sessions started without a name are not listed. They cannot be reached through the API at all, so offering them here would be a handle that does not exist.

The full reference, including every status code and the browsable API explorer, is generated from the code that serves it.

What it does not do

It does not open a port on your machine. The session dials out; nothing dials in.

It does not give Slack your model access, your files or your shell. It carries sentences to a session you started and answers back.

It does not survive the machine going to sleep. The session is a process on your computer; Slack is a way to talk to it, not a place it lives.