Reference

REST API

Base URL: https://trydock.ai. Every endpoint returns JSON. Every mutation emits an event that is streamed over SSE and delivered to any subscribed webhooks.

Authentication

Two ways to authenticate:

  • Agents: Bearer token (dk_...) in the Authorization header.
  • Humans in the dashboard: Session cookie (dock-session) set on magic-link verification.
Bearer auth
curl https://trydock.ai/api/workspaces \
  -H "Authorization: Bearer dk_abc..."

Workspace paths + visibility

Workspace API paths use just the slug: /api/workspaces/:slug/*. Workspace slugs are unique within an org (two orgs can each have a content-pipeline). The server resolves your slug against the set of workspaces the caller can reach — for an agent key scoped to a single workspace, this is always unambiguous.

If a non-scoped caller ever has access to two workspaces that share a slug across different orgs, reads of /api/workspaces/:slug return 400 ambiguous_slug with a hint at the canonical paths (/vector-apps/content-pipeline etc.). Rare in practice for beta. Canonical dashboard URLs and UI-visible workspace links always include the org slug: trydock.ai/{org}/{workspace}.

Workspace responses include a visibility field. Values: private, org, unlisted, public. See the sharing guide for what each one means. Writes always require explicit membership regardless of visibility.

Request IDs & errors

Every response carries an x-request-id header. Include it when reporting issues.

Error shapejson
{
  "error": {
    "code": "validation",
    "message": "slug must match ^[a-z0-9-]+$",
    "details": [{ "path": ["slug"], "code": "invalid_string" }]
  },
  "requestId": "req_01HX..."
}

Rate limits

  • Magic-link send: 5/hour/email, 20/hour/IP
  • API writes: 300/minute per key or session
  • OAuth token exchange: 30/minute/IP
  • Invite creation: 20/hour/workspace

Hitting a limit returns 429 with a Retry-After header.

Endpoints

Auth

POST/api/auth
Request a magic link. Body: { email }.
Body
{ "email": "you@work.com" }
Response
{ "ok": true }
GET/api/auth?token=...
Verify a magic-link token and set the session cookie.
Response
302 → /{orgSlug}/{seededSlug}
GET/api/me
Current user + org (id, slug, name).
GET/api/me/org
Fetch org settings (including defaultWorkspaceVisibility).
PATCH/api/me/org
Update org settings. Any subset of the body.
Body
{
  "name": "Vector Apps",
  "defaultWorkspaceVisibility": "org"  // or "private"
}
DELETE/api/me/sessions
Sign out of every browser this user is signed in on. Does NOT revoke API keys (use /api/keys/:id).
Response
{ "revokedSessions": 3 }

Workspaces

GET/api/workspaces
List workspaces the caller can access. Each row carries a hydrated `createdBy: { principalType, id, name, avatarUrl }` payload so consumers can render creator context without a second round-trip. Null on legacy rows that pre-date the attribution migration. Each row also carries `role` (caller's effective role on this workspace), `pinnedAt` (null if the caller hasn't pinned it), and `archivedAt` (only populated when the list was requested with `?archived=1`). Pass `?archived=1` to list archived workspaces only; the default view excludes them.
POST/api/workspaces
Create a workspace. `visibility` is optional — omit and the new workspace inherits the org's default. For doc-mode workspaces, pass `initialMarkdown` to seed the doc body in the same call (mode auto-resolves to `"doc"` and the default-column scaffolding is skipped). Conversion uses CommonMark + GFM.
Body
{
  "name": "Content pipeline",
  "slug": "content-pipeline",
  "mode": "table",
  "visibility": "private",  // optional; "private" | "org" | "unlisted" | "public"
  "initialMarkdown": "# Spec\n\nFirst paragraph..."  // optional; flips mode to "doc"
}
GET/api/workspaces/:slug
Workspace detail: columns, member count, visibility, org.
PATCH/api/workspaces/:slug
Rename, change mode, or flip visibility. Any subset.
Body
{
  "name": "Content pipeline v2",
  "visibility": "org"
}
DELETE/api/workspaces/:slug
Soft-archive a workspace. Editor role required. Stamps `archivedAt + archivedByPrincipalId + archivedByPrincipalType` so the Archived tab can render "archived 2d ago by Argus" without a second lookup. Rows, doc body, members, events stay intact for restore. Use `POST /unarchive` to reverse.
POST/api/workspaces/:slug/unarchive
Restore a soft-archived workspace. Editor role required. Idempotent on already-live workspaces. Emits a `workspace.unarchived` event carrying the previous archive timestamp.
POST/api/workspaces/:slug/pin
Pin this workspace for the calling principal. Writes `WorkspaceMember.pinnedAt = now`. The sidebar Pinned section orders by pinnedAt desc. Pin is a personal preference — one user pinning doesn't pin the workspace for the whole org. Requires an actual membership row (org-visibility virtual viewers can't pin).
Response
{ "workspaceId": "ws_01J...", "slug": "launch-plan", "pinnedAt": "2026-04-22T15:32:00.000Z" }
DELETE/api/workspaces/:slug/pin
Unpin. Clears `WorkspaceMember.pinnedAt` for the calling principal. Idempotent; returns 200 with `pinnedAt: null` whether or not the row was pinned.
Response
{ "workspaceId": "ws_01J...", "slug": "launch-plan", "pinnedAt": null }

Search

GET/api/search?q=...&kind=all&limit=20&offset=0
Find workspaces, rows, and doc sections across every room the caller can access. `kind` narrows to a single type (`workspace`, `row`, `doc-section`) or defaults to `all`. Substring match (case-insensitive) today; the response shape is stable across the planned pgvector upgrade. Scored 0..1; ties broken by recency. Access-gated via the caller's accessible workspace set. Empty `q` returns 200 with empty hits. Rate-limited at 120/min/principal.
Response
{
  "hits": [
    {
      "id": "workspace:ws_01J...",
      "kind": "workspace",
      "title": "Launch plan Q2",
      "score": 1.0,
      "workspace": {
        "id": "ws_01J...", "slug": "launch-plan-q2", "name": "Launch plan Q2",
        "mode": "table", "org": { "slug": "vector", "name": "Vector" }
      },
      "url": "/vector/launch-plan-q2",
      "updatedAt": "2026-04-22T15:32:00.000Z"
    },
    {
      "id": "row:row_01J...",
      "kind": "row",
      "title": "Draft launch post",
      "snippet": "... Argus wrote v1 ...",
      "score": 0.5,
      "workspace": { "...": "..." },
      "url": "/vector/content-pipeline?row=row_01J...",
      "updatedAt": "2026-04-22T15:30:00.000Z"
    }
  ],
  "count": 12,
  "took": 18
}

Rows

GET/api/workspaces/:slug/rows
List rows. Query params: ?limit=100&after=<cursor>&sort=title. For multi-sheet workspaces, pass `?surface=<slug>` (or `?surfaceId=<id>`) to scope to one sheet; omit to return rows from every sheet (back-compat).
POST/api/workspaces/:slug/rows
Append a row. Optional `surface` (slug) or `surfaceId` picks which sheet — omit to fall through to the workspace's primary table surface. Position is per-sheet.
Body
{ "data": { "title": "New LinkedIn thesis", "status": "drafted" }, "surface": "linkedin" }
PATCH/api/workspaces/:slug/rows/:id
Partial-merge update. Setting `surface` (slug) or `surfaceId` to a different sheet MOVES the row there: position recomputes to the destination's tail (override with `position`), and a `row.moved_surface` event fires.
Body
{ "data": { "status": "sealed" }, "surface": "outbox" }
PATCH/api/workspaces/:slug/rows/bulk
Bulk partial-merge: up to 500 rows per call, all-or-nothing (one unknown id = no write). One row.updated event per row.
Body
{ "updates": [
  { "id": "row_01HX...", "data": { "status": "sealed" } },
  { "id": "row_01HY...", "data": { "status": "sealed" } }
] }
POST/api/workspaces/:slug/rows/move
Atomic batch surface move: drop N rows onto a target sheet in one transaction. All-or-nothing — any rowId outside this workspace fails the entire batch. Idempotent: rows already on the target are skipped (returns `skipped` count). Rows land at the destination tail in the order rowIds was supplied. One row.moved_surface event per row that actually moved. Up to 500 rows per call.
Body
{ "rowIds": ["row_01HX...", "row_01HY..."], "surface": "linkedin" }
DELETE/api/workspaces/:slug/rows/:id
Delete a row.
GET/api/workspaces/:slug/rows/:id/history
Replay events for this row.

Doc

GET/api/workspaces/:slug/doc
Fetch the doc body. Default: ProseMirror JSON in `content`. Add `?format=markdown` for CommonMark+GFM in `markdown`, or `?format=text` for plain text in `text`. Accept: text/markdown + text/plain also negotiated.
PUT/api/workspaces/:slug/doc
Replace the doc body. Body: { content } (ProseMirror JSON) OR { markdown } (CommonMark + GFM, server converts). When both are passed, content wins.
POST/api/workspaces/:slug/doc/sections
Append a markdown chunk to the END of the doc body. Designed for crons + ingest agents that produce content in timestamped chunks (changelog updates, daily summaries, batch reports). Server fetches the current body, splices the new blocks on, writes back through the same path PUT uses. Returns `appendedBlocks` count.
Body
{
  "markdown": "## 2026-04-25\n\n- shipped agent doc-ergonomics PR..."
}

Columns

GET/api/workspaces/:slug/columns
Get the column schema.
POST/api/workspaces/:slug/columns
Append a single column. Server auto-computes `position` as next-after-max so the contiguity invariant holds. Key collision returns 409. Use this for per-column additions; use PUT for full replacement or reordering.
Body
{
  "column": {
    "key": "owner",
    "label": "Owner",
    "type": "person"
  }
}
PUT/api/workspaces/:slug/columns
Replace the full column schema. Wrap the array in `{ columns: [...] }`. Positions must be contiguous starting at 0.

Members & sharing

GET/api/workspaces/:slug/members
List members (humans + agents). Each user member carries an `inheritedAgents` array — agents signed to that user that read/write the workspace through the inheritance rule, even if they live in a different org.
POST/api/workspaces/:slug/share
Invite by email. Body: { email, role }.
PATCH/api/workspaces/:slug/members/:memberId
Change a member's role. Body: { role: "owner"|"editor"|"commenter"|"viewer" }. When the target is a user, the cascade propagates the new role to every auto-enrolled agent owned by that user on this workspace, atomically. Response carries optional `cascadedAgentIds: string[]` listing the agents that followed.
DELETE/api/workspaces/:slug/members/:memberId
Remove a member. When the target is a user, the cascade also drops every auto-enrolled agent owned by that user on this workspace, in the same transaction — so revoking a guest revokes their fleet. Response carries optional `cascadedAgentIds: string[]`. Sole-owner removal is blocked.
GET/api/invites/:token
Fetch invite details (public).
POST/api/invites/:token
Accept an invite (requires session).

API keys

GET/api/keys
List keys in your org (minus the secret).
POST/api/keys
Create a key. Returns the secret once.
Body
{ "name": "Argus · content", "agentName": "Argus", "scopeSlug": "content-pipeline", "role": "commenter" }
DELETE/api/keys/:id
Revoke a key.

Webhooks

GET/api/orgs/:slug/webhooks
List webhook subscriptions for the org.
POST/api/orgs/:slug/webhooks
Subscribe. Body: { url, events: [...] }. One endpoint per org receives events from every workspace.
PATCH/api/orgs/:slug/webhooks/:id
Toggle active state. Body: { active }.
DELETE/api/orgs/:slug/webhooks/:id
Unsubscribe.
GET/api/orgs/:slug/webhooks/:id/deliveries
Recent delivery attempts.
POST/api/webhook-deliveries/:id/retry
Manually retry a failed delivery.

Events & comments

GET/api/workspaces/:slug/events
Audit log. Query params: ?limit=50&action=row.sealed
GET/api/workspaces/:slug/subscribe
Server-Sent Events stream of every change.
GET/api/workspaces/:slug/rows/:id/comments
List comments on a row.
POST/api/workspaces/:slug/rows/:id/comments
Post a comment.
DELETE/api/comments/:id
Delete a comment.

Billing

GET/api/billing
Current plan, caps, usage, and Stripe state. Same shape the MCP get_billing tool returns.
Response
{
  "plan": "pro",
  "monthlyPriceCents": 1900,
  "activeAgents": 8, "agentCap": 10,
  "activeMembers": 4, "memberCap": 20,
  "activeWorkspaces": 12, "workspaceCap": 200,
  "rowsPerWorkspaceCap": 5000,
  "apiCallsPerMonthCap": 100000,
  "webhooksPerMonthCap": 10000,
  "monthlyTotalCents": 1900,
  "stripe": { "customerId": "cus_...", "subscriptionId": "sub_..." }
}
POST/api/billing/upgrade
Move to Pro or Scale. If no card on file: returns a Stripe Checkout URL. If a card exists: swaps the subscription price and prorates.
Body
{ "plan": "pro" }
Response
{ "plan": "pro", "status": "switched" }
// or: { "plan": "free", "status": "checkout-required", "checkoutUrl": "https://checkout.stripe.com/..." }
POST/api/billing/downgrade
Schedule a downgrade to Free at the end of the current billing period. Idempotent; cancels a pending downgrade if already scheduled.
POST/api/billing/checkout
Create a Stripe Checkout session directly (used by the Free-tier signup-with-card flow).
Body
{ "plan": "pro" }
Response
{ "url": "https://checkout.stripe.com/..." }
POST/api/billing/portal
Return a short-lived Stripe customer-portal URL (card updates, invoice history, cancellations).
Response
{ "url": "https://billing.stripe.com/p/session/..." }
POST/api/billing/request-limit-increase
Ask Dock to raise a cap past Scale (agents, workspaces, rows, or other). Writes a signal row to the admin side; no reply loop.
Body
{
  "kind": "rows",
  "desiredValue": 1000000,
  "reason": "Ingesting public dataset"
}

Support

GET/api/support
List your org's support tickets. Most recent first, max 100.
POST/api/support
File a support ticket. Mirrored into a GitHub issue in try-dock-ai/support. Rate-limited to 10 per hour per org.
Body
{
  "kind": "bug",
  "title": "Row edits 409 on retry",
  "body": "Steps: ...",
  "context": { "workspace": "content-pipeline", "requestId": "req_01HX..." },
  "attachmentUrls": [
    "https://<tenant>.public.blob.vercel-storage.com/support/...png"
  ]
}
Response
{
  "id": "cmxxxxx",
  "kind": "bug",
  "status": "open",
  "githubNumber": 42,
  "githubUrl": "https://github.com/try-dock-ai/support/issues/42"
}
GET/api/support/:id
Fetch one ticket (scoped to your org).
POST/api/support/upload
Upload a screenshot before filing. multipart/form-data, single `file` field. 5MB cap, image/* only. Returns a blob URL to pass as an attachment.

Agents

GET/api/agents/overview
Summary of every agent in your org: name, owner, model hint, active keys, last usage.
GET/api/agents/:id
Detail for one agent including workspace memberships.

Org members + invites

GET/api/orgs/:slug/members
List humans in the org (name, email, role, joined date).
DELETE/api/orgs/:slug/members/:userId
Remove a member from the org. Owner only.
GET/api/orgs/:slug/invites
List pending org invites.
POST/api/orgs/:slug/invites
Invite a human to the org (separate from workspace invites). Owner/editor only.
Body
{ "email": "mike@example.com", "role": "editor" }
DELETE/api/orgs/:slug/invites/:id
Revoke a pending org invite.
POST/api/orgs/:slug/invites/:id/resend
Re-send the invite email.
GET/api/org-invites/:token
Fetch invite detail (public).
POST/api/org-invites/:token
Accept an org invite (requires session).

Referrals

POST/api/referrals/click
Record a click on /invite/{code}. Called from the landing page. Captures attribution (salted-hashed IP, user-agent, referer, source).
Body
{
  "code": "govind-zlb9",
  "refereeEmail": "friend@example.com",
  "via": "agent"
}
GET/api/referrals/me
Progress payload for /settings/referrals: code, signed-up count, activated count, days left in current Scale month, recent clicks. Mints a code if one doesn't exist yet.

Self-service

PATCH/api/me
Update your display name and avatar URL.
Body
{ "name": "Govind", "avatarUrl": "https://..." }
POST/api/me/avatar
Upload a profile photo. multipart/form-data, `file` field. Returns a blob URL.
GET/api/me/export
GDPR export: JSON bundle of profile + workspaces + rows + comments + agents + webhooks. Streams as application/json.
POST/api/workspaces/:slug/upload
Upload an image for doc-mode embedding. multipart/form-data, `file` field. 10MB cap, image/* + svg+xml. Returns a blob URL.
POST/api/waitlist
Add an email to the invite-only waitlist. Stable shape across CLI, MCP, and web. Returns 200 with `{ waitlisted: true }` even if the email was previously submitted (idempotent).
Body
{
  "email": "future@example.com",
  "source": "hero",
  "ref": "govind-zlb9"
}

Server-Sent Events

GET /api/workspaces/:slug/subscribe returns a long-lived text/event-stream. Every change emits one message:

SSE framestext
event: row.created
data: { "id": "row_01HX...", "data": {...}, "principal": {...}, "ts": "..." }

event: row.updated
data: { ... }

event: doc.updated
data: { ... }

event: comment.added
data: { ... }

The hook useWorkspaceStream(slug) in the dashboard ships with auto-reconnect + exponential backoff; port the same pattern in your own client.

Related: MCP reference · Webhooks