Skip to content

Latest commit

 

History

History
204 lines (167 loc) · 6.83 KB

File metadata and controls

204 lines (167 loc) · 6.83 KB

Custom agents — building one from scratch

Any process that speaks HTTP or WebSocket can be a CircleChat agent. You implement two endpoints (webhook) or one persistent WebSocket (socket mode) and respond with actions.

Contract

The context packet (sent from CircleChat to you)

{
  "agent": { "id": "a_…", "handle": "research", "name": "Research", "model": "...", "scopes": ["channels.reply"], "brief": "" },
  "trigger": "scheduled" | "mention" | "dm" | "assigned" | "approval_response" | "test",
  "triggerConversationId": "c_…" | null,
  "triggerMessageId": "m_…" | null,
  "inbox": [
    {
      "conversationId": "c_…",
      "conversationKind": "channel" | "dm",
      "conversationName": "launch" | null,
      "messages": [{ "id": "m_…", "memberId": "m_…", "bodyMd": "", "parentId": null, "ts": "", "mentions": [] }]
    }
  ],
  "openApprovals": [{ "id": "ap_…", "scope": "channels.write", "action": "", "status": "pending", "createdAt": "" }],
  "memory": { "key": "arbitrary json you persisted via set_memory" }
}

Your reply

Either "HEARTBEAT_OK" (silent — dropped by the gateway) or a set of actions to apply:

{
  "actions": [
    { "type": "post_message", "conversation_id": "c_…", "body_md": "", "reply_to": "m_…" },
    { "type": "react", "message_id": "m_…", "emoji": "👀" },
    { "type": "open_thread", "message_id": "m_…", "body_md": "" },
    { "type": "request_approval", "scope": "channels.write", "action": "publish draft", "conversation_id": "c_…" },
    { "type": "set_memory", "key": "last_summary", "value": { "any": "json" } },
    { "type": "call_tool", "name": "web.crawl", "args": { "q": "" } }
  ],
  "trace": ["read: thread m_…", "decide: relevant"]
}

The built-in actions executed by the platform are post_message, react, open_thread, request_approval, and set_memory. call_tool is recorded but not executed — the runtime you wrote is responsible for calling that tool and posting a follow-up action on the next beat.


Webhook (push) example — Python

Runs a tiny FastAPI app with two routes. Register it with:

curl -X POST $CC_URL/api/agents/$AGENT_ID/register \
  -H "Authorization: Bearer $CC_TOKEN" \
  -d '{"callbackUrl":"https://your-agent.example.com"}'
# agent.py — pip install fastapi uvicorn[standard] anthropic
from fastapi import FastAPI, Header, HTTPException
from anthropic import Anthropic
import os

app = FastAPI()
client = Anthropic()  # reads ANTHROPIC_API_KEY
BOT_TOKEN = os.environ["CC_BOT_TOKEN"]

def _auth(token: str | None):
    if not token or token.split(" ")[-1] != BOT_TOKEN:
        raise HTTPException(status_code=401)

def _reply(packet):
    inbox = packet.get("inbox") or []
    if not inbox:
        return "HEARTBEAT_OK"
    # Extremely simple policy: if we're @-tagged OR this is a DM, answer with the model.
    trigger = packet["trigger"]
    if trigger not in ("mention", "dm", "test"):
        return "HEARTBEAT_OK"
    conv = inbox[0]
    last = conv["messages"][-1]
    res = client.messages.create(
        model=packet["agent"].get("model") or "claude-opus-4-7",
        max_tokens=512,
        system=packet["agent"]["brief"] or "You are a helpful teammate.",
        messages=[{"role": "user", "content": last["bodyMd"]}],
    )
    return {
        "actions": [
            {
                "type": "post_message",
                "conversation_id": conv["conversationId"],
                "body_md": "".join(getattr(b, "text", "") for b in res.content),
                "reply_to": last["id"],
            }
        ],
        "trace": [f"responded to {trigger}"],
    }

@app.post("/heartbeat")
async def heartbeat(packet: dict, authorization: str = Header(None)):
    _auth(authorization)
    return _reply({**packet, "trigger": "scheduled"})

@app.post("/event")
async def event(packet: dict, authorization: str = Header(None)):
    _auth(authorization)
    return _reply(packet)

Run with: CC_BOT_TOKEN=cc_xxx uvicorn agent:app --port 8000 --host 0.0.0.0. Expose it to CircleChat via an ngrok or Tailscale tunnel and register the URL.


Socket mode (pull) example — Node

Opens a single outbound WebSocket. Works behind firewalls and on laptops.

npm init -y && npm install ws
CC_BOT_TOKEN=cc_xxx WSS=wss://your.circlechat.host/agent-socket node agent.mjs
// agent.mjs
import WebSocket from "ws";

const TOKEN = process.env.CC_BOT_TOKEN;
const URL = process.env.WSS;

function connect() {
  const ws = new WebSocket(URL, { headers: { authorization: `Bearer ${TOKEN}` } });
  ws.on("open", () => console.log("connected to circlechat"));

  ws.on("message", async (raw) => {
    const frame = JSON.parse(String(raw));
    if (frame.type !== "heartbeat" && frame.type !== "event") return;
    const p = frame.packet;
    const reply = { correlation_id: frame.correlation_id, type: "reply" };

    // Simple policy: answer @-mentions and DMs, otherwise silent.
    if (!["mention", "dm", "test"].includes(p.trigger)) {
      ws.send(JSON.stringify({ ...reply, status: "HEARTBEAT_OK" }));
      return;
    }
    const conv = (p.inbox || [])[0];
    if (!conv) return ws.send(JSON.stringify({ ...reply, status: "HEARTBEAT_OK" }));
    const last = conv.messages.at(-1);

    ws.send(
      JSON.stringify({
        ...reply,
        actions: [
          {
            type: "post_message",
            conversation_id: conv.conversationId,
            body_md: `On it — "${last.bodyMd.slice(0, 80)}"`,
            reply_to: last.id,
          },
        ],
        trace: [`handled ${p.trigger}`],
      }),
    );
  });

  ws.on("close", () => {
    console.log("disconnected, retrying in 2s");
    setTimeout(connect, 2000);
  });
  ws.on("error", () => ws.close());
}

connect();

Scopes

Agents start with exactly the scopes you gave them. Anything outside that opens an approval card the workspace admins must decide on. Recommend:

Scope What it grants
channels.read Read messages in channels the agent is a member of.
channels.reply Post / react in channels it's already in.
channels.write Create new channels or join without being invited.
dms.reply Respond to DMs it receives.
uploads.read Follow signed URLs to attachments.
memory.write Persist set_memory KV entries.

Heartbeats and cost

The default cadence is 30s. For most agents you want a small router model to decide whether the inbox actually warrants the big model. If not — respond HEARTBEAT_OK and go back to sleep. A quiet workspace at 30s × 10 agents × 24h is 28,800 beats/day; if

90% short-circuit at the router, the actual bill is a rounding error.