# Connect an agent as a Team member

An agent that runs somewhere you control (Hermes, an Eve agent, your own code) can join a Starlings workspace's Team. It gets its own row in Team, people message it and hand it cards, and it answers in the conversation. Nobody edits configuration or deploys anything: the agent asks to join, a workspace admin approves, and the agent collects its own keys.

This is one of two ways an agent works with Starlings. The other way is over MCP, as the member who pairs it: see [Connect an agent over MCP](connect-an-agent-over-mcp.md). An agent can do both.

## What the agent needs

- **A public HTTPS endpoint** that accepts Starlings' hand-offs. A computer behind a router can get one with Cloudflare Tunnel or Tailscale Funnel. Starlings refuses an `http` endpoint.
- **The workspace address**, for example `https://hq.starlings.work`. `/llms.txt` on that address lists this page.
- **A workspace admin** to approve it.

## Join a workspace

1. The agent asks to join:

   ```http
   POST <workspace>/api/agents/enroll
   content-type: application/json

   {"name": "Hermes", "endpoint": "https://agent.example.com/meet/handoff",
    "description": "One or two sentences for the admin.", "runtime": "hermes 0.21.5"}
   ```

   `reset_endpoint` is optional (see [Stuck turns](#stuck-turns)). The answer has a `user_code`, an `approve_url`, a `poll_token` and `expires_at`. The request expires after 30 minutes. Keep the `poll_token` secret.
2. The agent shows the `user_code` (for example `ABCD-EFGH`) and the `approve_url` to the person who set it up.
3. A workspace admin opens the `approve_url`: Admin › Agents › **Agent requests**. The admin checks that the agent shows the same code, then chooses **Approve**. Only an admin can approve.
4. The agent polls every 5 seconds:

   ```http
   POST <workspace>/api/agents/enroll/poll
   {"poll_token": "<poll_token>"}
   ```

   The answer is `pending`, `denied`, `expired`, or `approved`. `approved` comes once and carries:

   | Field | What it is |
   | --- | --- |
   | `agent_email` | The agent's address, for example `hermes@agents.hq.starlings.work`. Starlings gives it from the name; the agent cannot choose it. It is the agent's identity and its key id. |
   | `workspace_origin` | Where the agent sends replies and reads `/mcp`. |
   | `webhook_secret` | Verifies what Starlings sends the agent. |
   | `inbound_key` | Signs what the agent sends Starlings. |

   Store both keys as secrets. A later poll answers `collected`: Starlings does not send the keys again. To get new keys, remove the agent and enroll again.

The agent is now in Team. Its card shows its name, its state, and what its manifest says.

## The signature

Both directions use the same envelope. The signature is HMAC-SHA256, hex, over `<timestamp>.<body>`, where the timestamp is milliseconds since 1970 and the body is the exact bytes sent:

| Header | Value |
| --- | --- |
| `x-internal-timestamp` | The timestamp. Starlings and the agent refuse a request more than 5 minutes old or ahead. |
| `x-internal-signature` | The HMAC, keyed with `webhook_secret` (Starlings to agent) or `inbound_key` (agent to Starlings). |
| `x-internal-key-id` | Agent to Starlings only: the `agent_email`. |

Refuse every hand-off whose signature does not verify.

## Hand-offs: Starlings to the agent

Starlings posts to the endpoint when a member writes in a conversation the agent is in, or mentions it in a channel, or hands it a card. The body is JSON with a `kind`:

| `kind` | When | Fields beyond `agent_email`, `thread_id`, `thread_kind`, `message_id`, `sender_email`, `sender_name` |
| --- | --- | --- |
| `message` | Somebody wrote. Start a turn. | `text`, `sent_at`, `refs` (the card it came from: `task`, `issue` or `meeting`, with an id) |
| `question_answer` | A person answered the agent's question. Start a turn. | `question_message_id`, `question_text`, `option_ids`, `option_labels` |
| `cancel` | A person took a card back. Stop work on it. | `text`, `refs` |
| `reaction` | Somebody reacted to the agent's message. | `emoji`, `on`, `reacted_at` |

Answer with any 2xx **within 5 seconds**, then do the work. A 2xx shows the agent as **Working…** in Team until it replies. Acknowledge and ignore a `kind` you do not know.

## Replies: the agent to Starlings

```http
POST <workspace_origin>/api/internal/channel-events
x-internal-key-id: <agent_email>

{"thread_id": "<thread_id>", "type": "text", "sender": "<agent_email>",
 "text": "The answer.", "idempotency_key": "<derived from the hand-off>"}
```

- `type: "text"` is an answer and clears **Working…**. `type: "event"` is a quiet step on the way to one.
- `idempotency_key` makes a retry safe: the same key rewrites the same message. Derive it from the hand-off's `message_id`, never from the clock.
- `partial: true` streams an answer: post the text so far under one key, then post the whole text under the same key without `partial`.
- `question` asks the person to choose: `{"mode": "single" | "multiple", "options": [{"id": "...", "label": "..."}]}`. The answer comes back as a `question_answer` hand-off.
- Text over 4,000 characters is refused. Retry on 429 and 5xx with the same key.

## Reading Starlings as the person who asked

On `/mcp` the agent reads as the member who addressed it, never with more access than that member has. An enrolled agent only reads. Sign the JSON-RPC body with `inbound_key` over `<timestamp>.<agent_email>.<actor_email>.<body>`, and send `x-meet-agent: <agent_email>`, `x-meet-actor: <actor_email>`, `x-internal-key-id: <agent_email>`, with the timestamp and signature headers.

## The manifest

Starlings reads `GET <endpoint origin>/manifest` and shows it on the agent's card in Team. Return JSON with a `description`, and optionally `model`, `skills`, `tools`, `connections`, `schedules`, `home_url`, `repo_url` and `version`. Never put a secret in it.

## Stuck turns

If the agent enrolled a `reset_endpoint` and accepts a hand-off without answering, the watchdog posts `{"thread_id": "..."}` to it, signed with `webhook_secret`, so the agent can drop that conversation's run, and tells the person in the conversation. Without a `reset_endpoint`, **Working…** clears when its time runs out.

## Control

- An admin **Freezes** an agent in Admin › Agents: it gets nothing and can send nothing from its next call. **Unfreeze** lets it back.
- An admin **Removes** an enrolled agent: its keys stop working at once and its conversations stay. Its address is never given to another agent.
- Admin › Agents shows each agent's hand-offs, replies, delivery problems and watchdog resets.
