Appearance
API Design
The Observatory exposes a REST API and WebSocket for real-time updates. All endpoints require authentication via a shared API key.
Authentication
All requests must include the API key in the header:
X-API-Key: your-shared-secretArm API Isolation
Critical: Arms must NOT have direct access to the Observatory API. If arms could call these endpoints with curl, they could:
- Kill other arms (
DELETE /api/arms/:id) - Override the brain (
POST /api/brain/stop) - Approve their own proposals (
POST /api/approvals/:id/approve) - Manipulate reputation scores
Isolation Architecture
┌─────────────────────────────────────────────────────────────┐
│ API ACCESS MODEL │
├─────────────────────────────────────────────────────────────┤
│ │
│ Human (Browser/CLI) │
│ │ │
│ │ X-API-Key: human-secret │
│ ▼ │
│ Observatory API (Full Access) │
│ │ │
│ │ │
│ Brain │
│ │ │
│ │ MCP Protocol (not HTTP) │
│ ▼ │
│ Arms (MCP Access Only) │
│ │ │
│ ✗ No HTTP access to Observatory │
│ ✗ No API key │
│ ✗ Network blocked to localhost:observatory-port │
│ │
└─────────────────────────────────────────────────────────────┘How Arms Communicate
Arms communicate ONLY through MCP, not HTTP:
typescript
// Arms use MCP tools, NOT curl
// WRONG - arm should never do this:
// curl -X DELETE http://localhost:8080/api/arms/other-arm
// RIGHT - arm uses MCP to request action:
await mcp.call("brain.request", {
action: "pause_arm",
target: "other-arm",
reason: "Detected conflict in shared file",
});
// Brain validates, logs, and may reject the requestNetwork Isolation
Arms are blocked from accessing the Observatory:
typescript
const ARM_BLOCKED_HOSTS = [
"localhost:8080", // Observatory API
"127.0.0.1:8080",
"observatory", // Docker network name
"host.docker.internal:8080",
];
// In Docker, use network policies
// In local dev, use firewall rules or process sandboxingScoped API Keys (Future)
For production, implement scoped keys:
typescript
interface APIKeyScope {
key: string;
type: "human" | "service" | "readonly";
permissions: Permission[];
armId?: string; // If service key, which arm
}
type Permission =
| "arms:read" | "arms:write" | "arms:kill"
| "proposals:read" | "proposals:write" | "proposals:resolve"
| "deploy:read" | "deploy:request" | "deploy:approve"
| "brain:control"
| "*"; // Human admin only
const HUMAN_KEY: APIKeyScope = {
key: "human-admin-key",
type: "human",
permissions: ["*"],
};
const READONLY_KEY: APIKeyScope = {
key: "dashboard-readonly",
type: "readonly",
permissions: ["arms:read", "proposals:read", "deploy:read"],
};Request Validation
All arm-affecting requests are validated:
typescript
async function validateArmAction(
request: Request,
action: string,
targetArmId?: string
): Promise<ValidationResult> {
const key = request.headers.get("X-API-Key");
const scope = await getKeyScope(key);
// Check if this is an arm trying to act on itself or others
if (scope.type === "service" && scope.armId) {
// Arms cannot kill other arms directly
if (action === "arms:kill" && targetArmId !== scope.armId) {
return { allowed: false, reason: "Arms cannot kill other arms" };
}
// Arms cannot approve proposals
if (action === "proposals:resolve") {
return { allowed: false, reason: "Arms cannot resolve proposals" };
}
}
return { allowed: scope.permissions.includes(action) || scope.permissions.includes("*") };
}REST Endpoints
System
http
GET /api/statusReturns overall system status.
json
{
"brain": { "status": "running", "uptime": 3600 },
"arms": { "total": 5, "active": 3, "paused": 1 },
"proposals": { "open": 2, "pending_human": 1 },
"garden": { "files": 1234, "conflicts": 0 }
}http
GET /api/healthHealth check endpoint (no auth required).
Brain
http
GET /api/brainGet brain state.
http
POST /api/brain/startStart the brain if stopped.
http
POST /api/brain/stopStop the brain gracefully.
Brain inbox/message endpoints
These are internal API endpoints used by the Brain worker and API bridge:
http
POST /api/brain/internal/messages/queue
GET /api/brain/internal/messages/pending?to=brain
GET /api/brain/internal/messages/deadletter
POST /api/brain/internal/messages/deadletter/:id/requeue
POST /api/brain/internal/messages/:id/status
POST /api/brain/internal/messages/cleanupNotes:
- Only allowlisted brain message types are accepted for
to=brain. - Invalid/unsupported messages are dead-lettered (
to_id = brain.deadletter) instead of silently dropped. - Brain workers acquire processing leases (
success: true|false) before handling messages.
Arms
http
GET /api/armsList all arms.
json
{
"arms": [
{
"id": "ui-arm",
"name": "UI Specialist",
"domain": "ui",
"status": "working",
"reputation": 75,
"contextUtilization": 0.65
}
]
}http
POST /api/armsSpawn a new arm.
json
{
"name": "Test Runner",
"agent": "opencode",
"domain": "testing",
"workdir": "/path/to/project"
}http
GET /api/arms/:idGet arm details.
http
DELETE /api/arms/:idKill an arm.
http
PATCH /api/arms/:idUpdate arm configuration.
http
GET /api/arms/:id/contextGet arm's current context (files, tokens).
http
GET /api/arms/:id/activityGet arm's activity log.
Activity endpoint semantics
The API has two different "activity" surfaces:
/api/arms/:id/activity: arm-scoped activity history (single arm timeline)./api/activity/*: cross-arm/system activity and transcript views from JetStream.
Use arm-scoped activity for per-arm debugging and /api/activity for global analysis dashboards.
http
POST /api/arms/:id/pausePause an arm.
http
POST /api/arms/:id/resumeResume a paused arm.
Tasks
http
GET /api/tasksList all tasks.
Query params:
status: "draft" | "pending" | "claimed" | "in_progress" | "completing" | "completed" | "failed" | "blocked" | "cancelled" (comma-separated for multiple)priority: "critical" | "high" | "normal" | "low"domain: filter by domainassignedTo: filter by assigned armphase: filter by phaselimit: max results (default 100)offset: pagination offset
http
POST /api/tasksCreate a new task.
http
GET /api/tasks/:idGet task details.
Query params:
include: "discussions" to include task comments.
http
PATCH /api/tasks/:idUpdate task fields.
http
DELETE /api/tasks/:idDelete a task.
http
POST /api/tasks/reorderReorder a task to a specific position.
json
{
"taskId": "task-123",
"toSortOrder": 0
}http
POST /api/tasks/:id/remove-from-planRemove a task from its source plan.md file and delete it from the database.
Task Discussions
Task Discussions provide threaded commenting functionality for tasks, allowing humans and arms to collaborate on task implementation. Each comment is associated with a specific task and can be a top-level comment or a reply to another comment.
http
GET /api/tasks/:id/discussionsList all comments for a task.
Query params:
limit: max results (default 50)offset: pagination offsetthreaded: "true" to return comments in a nested tree structure.
Response:
json
{
"discussions": [
{
"id": "comment-123",
"taskId": "task-456",
"content": "This task is blocked by PR-789",
"authorType": "human",
"authorId": "user-1",
"authorName": "Tim",
"client": "web",
"edited": false,
"deleted": false,
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
],
"totalCount": 1
}http
POST /api/tasks/:id/discussionsAdd a new comment to a task discussion.
Body:
json
{
"content": "This task is blocked by PR-456",
"parentId": "comment-789",
"authorType": "human",
"authorId": "user-1",
"authorName": "Tim",
"client": "web"
}http
PATCH /api/tasks/:id/discussions/:commentIdEdit a comment (24-hour window, author only).
Body:
json
{
"content": "Updated comment content",
"authorId": "user-1"
}http
DELETE /api/tasks/:id/discussions/:commentIdSoft delete a comment (author only).
Body:
json
{
"authorId": "user-1"
}http
POST /api/tasks/:id/discussions/mark-readMark comments as read for a user.
Body:
json
{
"userId": "user-1",
"lastReadCommentId": "comment-789"
}http
GET /api/tasks/:id/discussions/unreadGet unread comment count for a user.
Query params:
userId: User ID to check for unread comments
Response:
json
{
"unreadCount": 3
}Comment Structure
typescript
interface TaskComment {
id: string; // Unique comment identifier
taskId: string; // Associated task ID
parentId?: string; // Parent comment ID (for replies)
content: string; // Comment content
authorType: "human" | "arm" | "brain"; // Who created the comment
authorId: string; // Author identifier
authorName?: string; // Display name for author
client: "web" | "mail" | "mcp" | "cli"; // How the comment was created
edited: boolean; // Whether comment was edited
deleted: boolean; // Whether comment was soft-deleted
createdAt: string; // ISO timestamp
updatedAt: string; // ISO timestamp
}Threaded Discussions
Comments can be organized in a threaded structure where replies are nested under parent comments. The API supports both flat and threaded views of discussions.
Comment Lifecycle
- Creation: Comments are created with content, author information, and optional parent ID for replies
- Editing: Authors can edit their comments within a 24-hour window
- Soft Deletion: Comments can be deleted by their author but are soft-deleted to preserve context
- Read Tracking: Users can mark comments as read for notification purposes
Comment Metadata
Comments track additional metadata for better collaboration:
- Author Information: Type (human/arm/brain), ID, and display name
- Client Context: How the comment was created (web UI, email, MCP tool, CLI)
- Edit History: Whether the comment was edited and when
- Deletion Status: Soft deletion preserves context while hiding content
Task Discussions
http
GET /api/tasks/:id/discussionsList all comments for a task.
Query params:
limit: max results (default 50)offset: pagination offsetthreaded: "true" to return comments in a nested tree structure.
http
POST /api/tasks/:id/discussionsAdd a new comment.
json
{
"content": "This task is blocked by PR-456",
"parentId": "comment-789",
"authorType": "human",
"authorId": "user-1",
"authorName": "Tim",
"client": "web"
}http
PATCH /api/tasks/:id/discussions/:commentIdEdit a comment (24-hour window, author only).
http
DELETE /api/tasks/:id/discussions/:commentIdSoft delete a comment (author only).
http
POST /api/tasks/:id/discussions/mark-readMark comments as read for a user.
json
{
"userId": "user-1",
"lastReadCommentId": "comment-789"
}http
GET /api/tasks/:id/discussions/unreadGet unread comment count for a user.
Garden
http
GET /api/gardenGet full garden topology (3D coordinates for all files).
json
{
"nodes": [
{
"path": "src/components/Button.tsx",
"type": "file",
"coords": { "x": 15, "y": 45, "z": 30 },
"owner": "ui-arm",
"lastTouchedBy": "ui-arm",
"lastTouchedAt": "2024-01-15T10:30:00Z",
"conflictZone": false
}
]
}http
GET /api/garden/treeGet file tree with ownership markers.
http
GET /api/garden/claimsGet all active file claims.
http
GET /api/garden/conflictsGet current conflict zones.
http
GET /api/garden/activityGet recent file touch activity.
json
{
"activity": [
{
"path": "src/api/users.ts",
"armId": "api-arm",
"action": "write",
"timestamp": "2024-01-15T10:30:00Z"
}
]
}Proposals
http
GET /api/proposalsList proposals with optional filters.
Query params:
status: "open" | "accepted" | "rejected" | "all"type: Proposal typeauthor: Arm ID
http
POST /api/proposalsCreate a new proposal (usually done by arms, but can be human-initiated).
http
GET /api/proposals/:idGet proposal details including arguments and signals.
http
POST /api/proposals/:id/argueAdd an argument to a proposal.
json
{
"position": "for",
"content": "This change improves performance by 40%",
"evidence": ["benchmark-results.json"]
}http
POST /api/proposals/:id/signalAdd a signal (support/opposition).
json
{
"weight": 75,
"reason": "Looks good, tests pass"
}http
POST /api/proposals/:id/resolveHuman resolves an undecided proposal.
json
{
"decision": "accept",
"reason": "Approving despite mixed signals"
}Approvals
http
GET /api/approvalsList pending human approvals.
http
POST /api/approvals/:id/approveApprove a pending request.
http
POST /api/approvals/:id/rejectReject a pending request.
json
{
"reason": "Not ready for production yet"
}Deployments
http
GET /api/deploymentsGet deployment history.
http
POST /api/deploymentsRequest a deployment.
json
{
"environment": "staging",
"ref": "main",
"reason": "Weekly release"
}http
GET /api/deployments/:idGet deployment status.
Notifications
http
POST /api/notifications/subscribeSubscribe to push notifications.
json
{
"endpoint": "https://fcm.googleapis.com/...",
"keys": {
"p256dh": "...",
"auth": "..."
}
}http
DELETE /api/notifications/subscribeUnsubscribe from push notifications.
http
GET /api/notifications/vapidGet VAPID public key for push subscription.
Config
http
GET /api/configGet system configuration.
http
PATCH /api/configUpdate system configuration.
WebSocket
Connect to /ws for real-time updates.
Client → Server Messages
typescript
// Subscribe to channels
{
"type": "subscribe",
"channels": ["arms", "garden", "proposals"]
}
// Unsubscribe
{
"type": "unsubscribe",
"channels": ["garden"]
}Server → Client Messages
typescript
{
"channel": "arms",
"event": "arm.status",
"data": {
"armId": "ui-arm",
"status": "working",
"task": "Implementing dark mode toggle"
},
"timestamp": "2024-01-15T10:30:00Z"
}Channels
| Channel | Events |
|---|---|
arms | arm.spawned, arm.status, arm.activity, arm.killed, arm.paused |
garden | garden.claim, garden.touch, garden.conflict, garden.release |
proposals | proposal.new, proposal.argue, proposal.signal, proposal.resolved |
activity | All events (firehose) |
approvals | approval.new, approval.resolved |
deploy | deploy.requested, deploy.consensus, deploy.started, deploy.completed, deploy.failed |
Push Notifications
Using the Web Push API with VAPID for browser push notifications.
Payload Structure
typescript
interface PushPayload {
title: string;
body: string;
icon?: string;
tag?: string; // For replacing/grouping
data?: {
url?: string; // URL to open on click
proposalId?: string;
approvalId?: string;
};
}Push Triggers
| Event | Priority | Payload |
|---|---|---|
| Human approval needed | High | Link to approval page |
| Deployment to prod ready | High | Link to deployment |
| Arm misbehavior detected | High | Link to arm details |
| Proposal stalled | Medium | Link to proposal |
| Deployment completed | Low | Status summary |
Example Push
json
{
"title": "Approval Needed",
"body": "Deploy to production requires your approval",
"icon": "/icons/coleo.png",
"tag": "approval-123",
"data": {
"url": "/approvals/123",
"approvalId": "123"
}
}Error Responses
All errors follow this format:
json
{
"error": {
"code": "ARM_NOT_FOUND",
"message": "Arm with ID 'xyz' not found",
"details": {}
}
}Error Codes
| Code | HTTP Status | Description |
|---|---|---|
UNAUTHORIZED | 401 | Invalid or missing API key |
FORBIDDEN | 403 | Action not permitted |
NOT_FOUND | 404 | Resource not found |
CONFLICT | 409 | Resource state conflict |
VALIDATION_ERROR | 422 | Invalid request body |
INTERNAL_ERROR | 500 | Server error |
Rate Limiting
| Endpoint | Limit |
|---|---|
/api/* | 100 requests/minute |
/ws | 1 connection per client |
| Push notifications | 10/minute per subscription |
