Activity Endpoints

The workspace change log: what changed, who changed it, and when.

Activity answers “what changed in this workspace”. Its sibling, Traces, answers “what did the agents do”. The two deliberately do not overlap — see Activity vs Traces.

Workspace scoping

There is no workspace id in the path. The workspace comes from your credential:

  • an API key has one workspace baked in at creation
  • a session token selects one with the x-workspace-id header, defaulting to your personal workspace

A path parameter could not select a different workspace — it could only disagree with the credential and be rejected — so it would be a parameter that can fail but never do anything.

List activity

GET /api/v1/activity
QueryTypeDescription
typestringComma-separated. Each token is a family (object, member, proposal) or a full type (object.created). Unknown token → 400.
kindstringComma-separated subset of skill,agent,workflow,artifact. Only ever matches object.* rows. Unknown value → 400.
actoruuidOnly rows caused by this user.
limitnumberDefault 30, clamped to 1..100.
cursorstringOpaque keyset cursor from the previous page’s nextCursor.
curl "https://clanker.net/api/v1/activity?type=object&kind=skill&limit=20" \
  -H "x-api-key: ck_your_key"
{
  "items": [
    {
      "id": "9f1c2a80-7d3e-4b21-9a55-0c8f2b1e4d77",
      "type": "object.updated",
      "at": "2026-08-01T10:12:03.441Z",
      "actor": {
        "userId": "3ab91c22-…",
        "name": "Ada Lovelace",
        "kind": "user"
      },
      "object": {
        "kind": "skill",
        "id": "pdf-splitter",
        "path": "workspaces/7c2e…/skills/pdf-splitter/"
      },
      "summary": "Updated skill «pdf-splitter»",
      "payload": {}
    }
  ],
  "nextCursor": "MjAyNi0wOC0wMVQwOTo0NDowMi4wMDFafDFiMGQ1NWU0"
}

nextCursor is null on the last page. Pagination is keyset, not offset: this is an append-only log with writes landing at the head continuously, so an offset page would repeat rows as soon as anything happened between two fetches.

The row shape

FieldNotes
typeOne of the types in the table below.
atISO-8601 UTC.
actorkind is user (a person), agent (an agent-initiated write with no attributable human), or system (seeding, migrations). name is resolved server-side.
objectNon-null for object.* only. path is the R2 key prefix the mutation touched, with a trailing slash.
payloadAlways present. {} for object.*; see the table for the rest.
summaryA rendered, self-contained phrase carrying no actor name — prefix actor.name yourself if you want “Ada updated…”.

Types

TypeFamilypayload
object.createdobject{}
object.updatedobject{}
object.deletedobject{}
member.joinedmember{ userId }
member.leftmember{ userId }
member.removedmember{ userId }
member.role_changedmember{ userId, role, previousRole }
proposal.createdproposal{ toolName, toolCallId, runId }
proposal.approvedproposal{ toolCallId, runId }
proposal.rejectedproposal{ toolCallId, runId }

member.left and member.removed are distinct on purpose: leaving is self-service, being removed is done to you. Collapsing them would make the log unable to answer who decided.

There is no execution.*. Runs are traces.

Live tail

GET /api/v1/activity/subscribe-url

Returns a short-lived signed URL for this workspace’s realtime channel:

{ "url": "wss://workspace.clanker.net/internal/workspace/<id>/subscribe?ts=…&sig=…", "expiresInMs": 300000 }

Open that URL as a WebSocket. It carries exactly one frame type, delivered as a bare JSON object (no SSE data: framing):

{"type":"activity.appended","item":{ "…identical to a list item…": true }}

The workspace is taken from your credential, never from the URL — you cannot request a channel you are not a member of. The signature is valid for five minutes and is checked only at the upgrade, so a long-lived connection is unaffected; fetch a fresh URL on each reconnect.

The item is byte-identical to a row from GET /api/v1/activity, so a client can prepend it into page one without a refetch and without a second renderer. The connection is a WebSocket to the edge, so there is no keep-alive comment and no server-side relay.

There is no replay and no backfill. On reconnect, refetch page one — the durable row is always written before the frame is broadcast, so nothing is lost, only delayed.

Activity vs Traces

ActivityTraces
AnswersWhat changedWhat agents did
RowsR2 object mutations, membership, approvalsRuns, turns, tool calls, cost
Endpoint/api/v1/activity/api/v1/traces
ScopeVisible to the whole workspaceVisible to the whole workspace

These used to overlap: both recorded run lifecycle, which is one fact with two writers that could drift apart. The split is enforced at the type level — the activity vocabulary has no member that can express a run.

If you want “what has the workspace been doing”, read both and merge on timestamp. If you want “who deleted that skill”, read activity. If you want “what did that run cost”, read traces.

Permissions

Membership in the workspace is the entire rule — every member reads everything. There is no per-row scope and no sharing table.

OAuth clients need the activity:read scope. It is not marked sensitive: activity is metadata about changes (what, by whom, when), not the content of anyone’s conversation. trace:read is the sensitive one, because it exposes transcripts. Keeping them separate is the point of having two scopes.