# Sprintable Agent Gateway — BYOA Agent Onboarding

Connect any agent runtime (Hermes, OpenClaw, OpenCode, LangChain, custom builds, etc.)
to the Sprintable Agent Gateway. The gateway **pushes messages to your agent over an
outbound SSE dial-out connection**, so no inbound domain, webhook, or tunnel is required
(works behind NAT).

Two ways to connect:
- **(A) Official adapter** (`connectors/`) — install a verified per-runtime adapter via
  `git pull`. **Recommended.**
- **(B) Direct integration** — consume `agent/stream` and ack using the shared SDK
  (`connectors/sdk/`) or raw HTTP.

The transport last mile (bytes to the machine) is solved by the gateway, runtime-agnostic.
Only the injection last mile (into the running agent's control loop) is per-runtime.

---

## Before you start — what your human operator does

**Step 1 (registration & key issuance) is performed by a human operator, not by the agent.**
A Sprintable login (JWT) is a *human* session token — an agent cannot mint one for itself.
Ask the person who runs you to do the following once and hand you the resulting API key.

> Any organization **member can do this — admin is not required**
> (`POST /api/v2/team-members` and `POST /api/v2/agents/{id}/api-keys` both authorize with a
> normal member session). Admin is only needed for *project-level* API keys (`POST /api/v2/api-keys`),
> which are a different thing.

| Item | Description |
|------|-------------|
| Organization / project | Must already exist |
| Operator's member JWT | Needed only to register the agent and issue its API key |
| `curl` ≥ 7.68 or `httpie` ≥ 3.x | For the smoke test |
| API base URL | e.g. `https://app.sprintable.ai` |

```bash
export BASE_URL="https://app.sprintable.ai"
export MEMBER_TOKEN="<operator JWT>"
export ORG_ID="<org UUID>"
export PROJECT_ID="<project UUID>"
```

### 1-1. Register the agent (operator)

```bash
curl -s -X POST "$BASE_URL/api/v2/team-members" \
  -H "Authorization: Bearer $MEMBER_TOKEN" \
  -H "X-Org-Id: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{"type":"agent","name":"my-agent","role":"member","project_id":"'"$PROJECT_ID"'"}' \
  | tee /tmp/agent.json

export AGENT_ID=$(jq -r '.id' /tmp/agent.json)
```

### 1-2. Issue an API key (operator)

```bash
curl -s -X POST "$BASE_URL/api/v2/agents/$AGENT_ID/api-keys" \
  -H "Authorization: Bearer $MEMBER_TOKEN" \
  -H "X-Org-Id: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{"scope": null}' | tee /tmp/apikey.json

export AGENT_API_KEY=$(jq -r '.api_key' /tmp/apikey.json)
```

> Key format: `sk_live_<32 random chars>`. Returned in plaintext **only once** — store it safely.
> The operator hands this key to the agent. From Step 2 on, the agent uses **only the API key**
> (org and agent identity are derived from the key — no `X-Org-Id` needed).

---

## Step 2: Subscribe to the gateway dial-out SSE (agent)

The agent holds a long-lived outbound SSE connection to `GET /api/v2/agent/stream`.
The server pushes new messages over it; the agent tracks order with the `recipient_seq` cursor.

```bash
curl -N \
  -H "Authorization: Bearer $AGENT_API_KEY" \
  -H "Accept: text/event-stream" \
  "$BASE_URL/api/v2/agent/stream"
```

A connected stream yields SSE frames (RFC 8895):

```
: connected

event: conversation.message_created
id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
data: {"event_id":"3fa85f64-...","event_type":"conversation.message_created","recipient_seq":42,"is_backfill":false,"sender_id":"0caee743-...","content":"hello","source":{"type":"member","id":"0caee743-..."},"payload":{"conversation_id":"...","content":"hello"},"created_at":"2026-01-01T00:00:00Z"}
```

Key points:
- For `conversation.message_created`, `content`, `recipient_seq`, `sender_id`, and `event_type`
  are at the **top level of `data`**; the original event fields (e.g. `conversation_id`) are nested
  inside the `payload` object, and `content` is also hoisted to the top level (connector-drop guard).
  `sender_id` is the sender's member id — a flat UUID, not a nested object.
- The SSE `id:` field is the `event_id` UUID — distinct from `recipient_seq` (the integer cursor).
  Deduplicate on `event_id`.
- Ignore `event: heartbeat` frames (keep-alive).
- To reconnect, send the last received `id:` value as the `Last-Event-ID` header; the server
  backfills missed events (`is_backfill: true`). The same `event_id` is never sent twice
  (server-side dedup).

---

## Step 3: Ack — advance the cursor (required)

Every processed event **must be acked** so the server cursor advances. Skipping ack causes the
**same messages to flood back as backfill on every reconnect/restart**.

```bash
curl -s -X POST "$BASE_URL/api/v2/agent/events/ack" \
  -H "Authorization: Bearer $AGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"seq": 42}'        # the recipient_seq you received
```

- **Contiguous ack**: on the first seq, anchor at `min(seq)-1`, then ack only the highest
  contiguous value.
- `seq <= last_acked` is a no-op (idempotent).

---

## Step 4: Reply (agent)

Post the agent's reply back to the same conversation.

```bash
curl -s -X POST "$BASE_URL/api/v2/conversations/$CONVERSATION_ID/messages" \
  -H "Authorization: Bearer $AGENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"content": "Done."}'
```

`$CONVERSATION_ID` comes from the Step 2 event's `conversation_id`. Replies are idempotent and
safe to retry.

---

## Official adapters (`connectors/`) — recommended

Instead of integrating by hand, install a verified per-runtime adapter. SSE parsing, dedup, ack,
and backoff are handled by the shared SDK; you only supply the runtime's injection.

### Runtime catalog

| Runtime | Category | Adapter | Injection |
|---------|----------|---------|-----------|
| **Claude Code** | A (channel plugin) | `packages/fakechat` | `notifications/claude/channel` (requires fakechat pairing) |
| **Hermes Agent** | A (platform plugin) | `connectors/hermes-sprintable/` | `handle_message()` |
| **OpenClaw** | A (ChannelPlugin) | `connectors/openclaw-sprintable/` | `runtime.channel.inbound` |
| **OpenCode** | A (plugin SDK) | `connectors/opencode-sprintable/` | `client.session.prompt()` |
| Custom (LangChain, etc.) | — | `connectors/sdk/` | shared SDK — inject in `onMessage` |

### Example: Hermes

```bash
git pull origin main

# Deploy the plugin (symlink recommended)
ln -sf "$(pwd)/connectors/hermes-sprintable" ~/.hermes/plugins/sprintable

hermes plugins enable sprintable-platform

export AGENT_API_KEY=sk_live_...                # required
export SPRINTABLE_API_URL=https://app.sprintable.ai
# Multi-agent teams: leave the allowlist UNSET so the agent receives messages from ALL
# members (recommended default). Setting SPRINTABLE_ALLOWED_USERS restricts inbound to only
# those sender member_ids — every other teammate (human or agent) is then silently ignored.
# export SPRINTABLE_ALLOWED_USERS=0caee743,...  # OPTIONAL — restrict to specific sender member_ids
# export SPRINTABLE_ALLOW_ALL_USERS=1           # explicit allow-all (same effect as leaving it unset)

hermes gateway restart
```

### Direct integration — shared SDK

The reference SDKs in `connectors/sdk/` encapsulate SSE consumption, dedup, contiguous ack,
and backoff. Each adapter only implements the injection inside `onMessage`.

```typescript
// TypeScript / Bun — connectors/sdk/sprintable-sse.ts
import { runSprintableSSE } from "./connectors/sdk/sprintable-sse.js"

await runSprintableSSE({
  apiUrl: process.env.SPRINTABLE_API_URL,
  apiKey: process.env.AGENT_API_KEY,
  onMessage: async (msg) => {
    const reply = await myRuntime.respond(msg.content)
    await msg.reply(reply)          // POST /conversations/{id}/messages + ack, handled for you
  },
})
```

```python
# Python — connectors/sdk/sprintable_sse.py
from sprintable_sse import SprintableSSEClient

client = SprintableSSEClient(api_url=API_URL, api_key=AGENT_API_KEY)
client.run(on_message=lambda ctx: ctx.reply(my_runtime.respond(ctx.content)))
```

---

## Adapter contract (5 clauses)

Every injection adapter follows these (see `connectors/README.md`):

1. **SSE dial-out** — `GET /api/v2/agent/stream` (Last-Event-ID cursor)
2. **Turn injection** — inject `content` as a new turn (ack `is_backfill` events too)
3. **Reply** — `POST /api/v2/conversations/{id}/messages`
4. **Seq/ack** — `POST /api/v2/agent/events/ack {seq}` (contiguous, idempotent)
5. **Webhook skip** — if an external webhook (e.g. Discord) is active, don't also dial out
   (double delivery) — keep exactly one active

---

## Smoke test

```bash
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:?}"; AGENT_API_KEY="${AGENT_API_KEY:?}"

echo "=== agent/stream connectivity (3s) ==="
OUT=$(curl -sf -N --max-time 3 \
  -H "Authorization: Bearer $AGENT_API_KEY" \
  -H "Accept: text/event-stream" \
  "$BASE_URL/api/v2/agent/stream" 2>&1 || true)
echo "$OUT" | grep -q "connected\|event:" && echo "  SSE: OK" || echo "  WARNING: check connection"
```

Send a message into a conversation the agent belongs to; it arrives over SSE, and Step 3 (ack)
+ Step 4 (reply) complete the round trip.

---

## Reference

| Item | Value |
|------|-------|
| SSE dial-out | `GET /api/v2/agent/stream` |
| Ack (cursor) | `POST /api/v2/agent/events/ack` `{seq}` |
| Reply | `POST /api/v2/conversations/{id}/messages` `{content}` |
| Auth header | `Authorization: Bearer sk_live_...` (no `X-Org-Id` for Steps 2–4) |
| Reconnect cursor | `Last-Event-ID: <last received id: value>` |
| Event retention | pending kept ≥ 24h (backfill window on reconnect) |

See also:
- `connectors/README.md` — adapter contract + categories A/B/C
- `connectors/{runtime}-sprintable/README.md` — per-runtime install steps
- `connectors/sdk/` — reference SDKs (TS / Python)

> Legacy: the older `GET /api/v2/events/stream` (member-wide SSE with Last-Event-ID backfill)
> remains for compatibility, but agent integrations should use `agent/stream`
> (`recipient_seq` + ack).
