Reference
Changelog
What changed in the API contract, newest first. Additive changes ship inside /v1; breaking changes never do.
Notable changes to the Tobie External API contract: REST under https://api.tobie.team/v1, MCP at https://api.tobie.team/mcp, the webhook payloads and the published documents (OpenAPI, agent guide, llms.txt). The format follows Keep a Changelog 1.1.0. Versions are the contract version, API_VERSION in packages/api-core/src/version.ts.
Versioning and X-Tobie-Api-Version#
- The path carries the major version (
/v1). Additive changes do not change it: a new operation, a new optional input field, a new output field, a new value in an enum documented asopen, a new webhook event type, a new scope. Clients must ignore fields they do not know. - Every response, errors included, carries
X-Tobie-Api-Version: <major>.<minor>(contract1.0.0sends1.0). The minor rises with each release that adds to the contract; a patch release changes no shape and is not visible in the header. Log the header with any report of unexpected behaviour: it names the contract the server answered under. GET /v1/meta(MCP tooltobie.meta.get) returns the contract version, the versions in service with their sunset dates, the active deprecations, the MCP protocol versions and the caller's effective scopes and client scope.- A breaking change is never made inside
/v1. Removing or renaming an operation, field, scope or enum value, tightening validation of an existing input, or changing a status or problemcodeships as/v2beside/v1.
Deprecation policy#
- When
/v2ships,/v1stays in service, carryingDeprecationandSunset, for at least six months. - A deprecated version or operation answers with
Deprecation: @<unix seconds>(RFC 9745, when it became deprecated),Sunset: <HTTP-date>(RFC 8594, when it stops answering; never earlier than the deprecation) and twoLinkheaders: the notice withrel="deprecation"and the replacement withrel="successor-version". - Every deprecation is listed in
GET /v1/meta(deprecations, andversions[].sunset) and in this file: under "Deprecated" in the release that announces it, with its sunset date, and under "Removed" in the release that withdraws it. - Enum values documented as
closeddo not change within a major version. - Nothing is deprecated in 1.0.0:
deprecationsinGET /v1/metais empty.
[Unreleased]#
Server-side changes made after the 1.0.0 contract was drawn up. None changes a /v1 request or response shape; the one visible change is in the MCP discovery document below.
Changed#
- The developer portal on
https://api.tobie.teamnow needs a Tobie account for anything generated from the API registry or that connects an app (WP-API-847, 2026-10-16). Signed-in only:/connectand its guides, every/docs/<guide>page (the/docsoverview stays public),/referenceand everything under it, and the documents/openapi.json,/llms-full.txt,/agent-guide.mdand/search-index.json. A browser without a session is sent to/console/sign-inwith the page asnext; a program gets a401problem document (typeunauthorized) whosesign_inandrequest_accessmembers say how to get in./v1/openapi.jsonand/v1/agent-guide.mdanswer to a bearer credential (an API key or OAuth token), with no scope required, likeGET /v1/meta. Unchanged:/llms.txt,/.well-known/**,/health, every/v1operation and/mcp. The homepage, the audience pages,/security,/status,/changelogand/problemsstay public. Anyone can ask for access athttps://api.tobie.team/request-access; requests are reviewed by hand. /.well-known/oauth-protected-resourceand its/mcpvariant:scopes_supportednow lists the authorization server's scopes a sign-in requests (openid,email,profile,offline_access) instead of Tobie's API scopes. Hosted MCP clients (ChatGPT's agent connector, 2026-10-14) copy that list into the authorization request, and Supabase Auth refuses any Tobie scope there (invalid_request: unsupported scope), so every sign-in died before the consent page unless the connector's default scopes were cleared by hand. The Tobie scopes the MCP surface uses are still published, under the extension parametertobie_scopes_supported(same derivation, same canonical order); they are consented on the grant, never requested, andGET /v1/metareports what a credential actually holds.- Tasks visibility for operator principals (migration
20261014230000, OPS-003 G27): anoperatorprincipal now reads internal tasks (client_idnull) whatever its client scope, an empty scope included, throughtasks.list,tasks.get,tasks.timeline,actions.listandsearch(the index view is SECURITY INVOKER overtasks), and the comments of such a task throughtasks.comments.list. This is the dashboard's rule that every active operator sees the board. Measured on production on 2026-10-15: an operator with no client assignments read 0 of 71 tasks, all 71 internal. Aclient_adminprincipal and aservice_accountkey are not widened: an internal task stays invisible to both.task_assigneesandtask_attachmentskeep their own tenant test, so an internal task read by a scoped operator carries its owner alone inassigneeIdsand no attachments (follow-up; the assignees are fixed by G39 under Fixed). No request or response shape changes. - CRM and pipeline full coverage for operator principals (migration
20261015010000, OPS-003 G28, Josh's decision of 2026-10-15): anoperatorprincipal now reads every row, every client's and the internal ones, whatever its client scope, throughcrm.leads.list,crm.accounts.list,crm.contacts.list,crm.contacts.search,crm.opportunities.list,pipeline.runs.list,pipeline.runs.getandpipeline.events.list; on those reads aclientIdfilter outside the token's scope is served instead of answered 404. This is the dashboard's rule that every active operator sees the whole CRM and pipeline. Measured on production on 2026-10-15: an operator with no client assignments read 0 rows on all seven CRM and pipeline tables (unscoped: 359 accounts, 97 opportunities, 366 leads, 365 contacts, 777 touchpoints, 2 pipeline runs, 123 pipeline events). Aclient_adminprincipal and aservice_accountkey are not widened (a key keeps its scope and its 404); every other resource keeps the client scope; contact PII stays redacted withoutpii:read; writes are unchanged. No request or response shape changes;GET /v1/metastill reports the token's client scope.
- Internal task writes for operator principals (migration
20261016000000, OPS-003 G36): anoperatorprincipal now creates, updates, completes and comments on internal tasks (client_idnull) whatever its client scope, an empty scope included, throughtasks.create,tasks.update,tasks.complete,tasks.commentandtasks.batch_create; the write twin of G27.tasks.createaccepts a body with neitherprojectIdnorclientIdand creates an internal (team-board) task, which only an operator principal may do; aservice_accountkey and aclient_adminget 404 as before, and client-bound tasks keep the client scope. Additive: a body that was refused withvalidation_failedfor lacking both ids is now accepted for an operator. Co-assignees of an internal task are not stored for a scoped principal (api.set_task_assignees); the owner is (fixed by G39 under Fixed).
Fixed#
- Co-assignees of an internal task for an operator principal (migration
20261016100000, OPS-003 G39):tasks.createandtasks.updatenow storeassigneeIdson an internal task (client_idnull) for anoperatorprincipal whatever its client scope, an empty scope included, andtasks.listandtasks.getreturn them, with theassigneeIdfilter matching a co-assignee. Before,api.set_task_assigneesrefused a NULL-client task for a scoped principal and thetask_assigneespolicy hid its rows, so the stored set and the read set were the owner alone (the write still succeeded and answered what was stored). The rule is G27's and G36's: aservice_accountkey and aclient_adminare not widened, and client-bound tasks keep the client scope. No request or response shape changes.task_attachmentskeeps its tenant test. tasks.getandtasks.comments.listopen an internal task for an operator principal with a client scope (OPS-003 G33). G27 above let the database and the host read return the row, but the runtime's single-resource check still answered 404 for any task with no client unless the principal was unscoped, so an operator with no client assignments saw an internal task intasks.listand then gotnot_foundopening it or its comments. The check now passes an internal task for theoperatorkind only; a client-bound task still needs its client in scope, and aservice_accountkey or aclient_adminstill gets 404 for an internal task. Found 2026-10-15 when the Chief of Staff agent's afternoon run could not read the comments of the internal task "Review current 1099 agreement".- An operator whose
operators.roleisvieweroradmin(the dashboard's own vocabulary, not anadmin_rolelabel) no longer crashes every authenticated request withinvalid input value for enum admin_role.owner,adminandoperatorread the staff (operator)role_permissionsrow;viewerholds no permission, sopii:readnever reaches it; a client-admin membership role is read as itself when it is a label and holds nothing otherwise. Found 2026-10-14 whenbots@tobie.team, invited as a Tobie viewer, signed in from ChatGPT and listed 0 tools. - The execution-token minter accepts a signing JWK that carries the advisory members
key_opsanduse. WebCrypto refuses a private ECDSA import whosekey_opsnamesverify("Unsupported key usage for a ECDSA key"), so with such a key every read that reaches the database failed inside a 200 JSON-RPC result whiletobie.meta.getworked. The minter now imports the key material alone and states its one usage, sign. Found 2026-10-15 on the first authenticated attention brief.
Security#
- MCP command tools obey
API_WRITES_ENABLED. Before, only the REST command routes checked the write switch, so over MCP a token holding a write scope could write while REST answered 404. With the switch off every command tool now answersnot_found; read tools are unaffected. - OAuth audience tightening (RISK-004, WP-API-503). The token verifier has a switch,
API_AUD_LEGACY_MODE:accept(the default, today's behaviour) orreject. Inrejectmode an OAuth access token whose onlyaudisauthenticatedis refused with401 invalid_token; tokens whoseaudishttps://api.tobie.team/mcporhttps://api.tobie.team, and API keys, are unaffected. Only a token minted before the custom access token hook was enabled is affected; the fix for an agent is to reconnect (authorize again). - Token verification fails cleanly when Supabase's key set misbehaves. A JWKS fetch that hangs is abandoned after 5 s, a key set without a usable signing key never replaces a good one, a failing key endpoint is retried at most every 30 s while a cached set is still served, and with no key cached the answer is
503 not_configuredwithRetry-After: 30rather than a hung request or a401.
Closing RISK-004 (operator steps, production)#
- Enable the custom access token hook (
docs/external-api/GO_LIVE.mdstep 2.8). From then on new OAuth access tokens carryaud = https://api.tobie.team/mcp; dashboard sessions are untouched. - Connect one OAuth client afresh and decode the access token's payload locally (never paste the token anywhere):
audmust behttps://api.tobie.team/mcp. - Let the legacy tokens drain: at least one access-token lifetime (
jwt_expiry, 3600 s), and until the dailyAPI_AUD_LEGACY_COUNTaudit row written by theapi-housekeepingcron reportscount: 0for a full 24-hour window. - Set
API_AUD_LEGACY_MODE=rejecton the pm-dashboard production environment and redeploy (the host reads it once its wiring lands; until then the verifier keeps theacceptdefault). - Verify:
API_REQUESTaudit rows withpayload->>'aud_legacy' = 'true'stop appearing, and any stale token shows up aspayload->>'auth_reason' = 'aud_legacy_rejected'with status 401. - Allowlist audit:
API_OAUTH_CLIENT_MODEisallowlistin production; every OAuth client registered in Supabase Auth either has a row inapi_oauth_client_policieswith a deliberatescope_ceilingor is deleted. Record client ids and names only. - Record the evidence on the WP-API-503 row and mark RISK-004 CLOSED (roadmap Part 9.3).
Rollback: set API_AUD_LEGACY_MODE=accept (or remove it) and redeploy. Disabling the hook re-opens the transition, so switch to accept before disabling it.
[1.0.0] - unreleased#
The first public contract. It is released the day /v1 goes live in production (docs/external-api/GO_LIVE.md); writes, MCP, webhooks and the event stream belong to this contract and are switched on by flag, not by a version change.
Added#
- Credentials: API keys (
tobie_sk_live_...,tobie_sk_test_...) acting for the operator who owns them, and OAuth 2.1 access tokens for operators and client admins. Scopes areresource:verb; a credential's effective scopes are the intersection of what it was granted and what its owner's live role allows. - Reads, and commands in tiers: Tier 1 executes, Tier 2 and 3 return
202 Acceptedwith a proposal a human approves.Prefer: dry-runreturns the plan without writing;Idempotency-Keymakes a retry replay the first answer (Idempotency-Replayed: true);If-Matchwith theETagrefuses a lost update. - Cursor pagination (
items,nextCursor), RFC 9457 problem details, rate-limit headers (RateLimit-Policy,RateLimit,X-RateLimit-*),X-Request-Idandtraceparent. - The MCP server, generated from the same registry, with the same scopes and approvals.
- Long-running work as operations, the events feed and its Server-Sent Events stream, search, and file upload and download URLs; webhooks signed per the Standard Webhooks scheme, registered and managed at
/webhook-endpoints(a secret shown once, rotation with a 24-hour overlap, a synchronous:test, delivery history and replay). - Documents: OpenAPI 3.1 (
/v1/openapi.json), the interactive reference (/v1/docs), the agent guide (/v1/agent-guide.md) and/llms.txt; the TypeScript client inpackages/api-client.
Resources and operations#
Contract version 1.0.0, sent as X-Tobie-Api-Version: 1.0 on every response. 82 operations (43 reads, 39 commands) in 23 families, 54 of them also MCP tools; 32 scopes; 10 webhook event types. Routes, inputs and outputs: the agent guide and the OpenAPI document.
| Family | Reads | Commands (tier) | Scopes |
|---|---|---|---|
meta | meta.get | none | any credential |
clients | clients.list, clients.get, clients.summary, clients.health, clients.launch_readiness, clients.memberships.list | clients.publish_hub (T2) | clients:read, memberships:manage, reviews:release |
projects | projects.list, projects.get | projects.create (T2), projects.update (T1), projects.stage.advance (T2) | projects:read, projects:write |
tasks | tasks.list, tasks.get, tasks.comments.list | tasks.create (T1), tasks.update (T1), tasks.complete (T1), tasks.comment (T1), tasks.batch_create (T1) | tasks:read, tasks:write |
timeline | timeline.list | none | tasks:read |
actions | actions.list | none | actions:read |
notifications | notifications.list | notifications.read (T1) | notifications:read |
intake | intake.list, intake.get | intake.create (T2), intake.draft.set_field (T1), intake.reopen_field (T2), intake.finalize (T2) | intake:read, intake:write |
extraction | extraction.get | none | extraction:read |
reviews | reviews.list, reviews.get | reviews.release (T2) | reviews:read, reviews:release |
pipeline | pipeline.runs.list, pipeline.runs.get, pipeline.events.list | pipeline.run.start (T2), pipeline.run.rerun_partial (T1) | pipeline:read, pipeline:run |
crm | crm.leads.list, crm.accounts.list, crm.contacts.list, crm.contacts.search, crm.opportunities.list | crm.lead.create (T1), crm.touchpoint.log (T1), crm.opportunity.convert (T2) | crm:read, crm:write |
campaigns | campaigns.list | campaigns.propose_send (T2) | campaigns:read, campaigns:propose |
files | files.download_url | files.upload_url (T1), files.register (T1) | files:read, files:write |
search | search.query | none | search:read |
approvals | approvals.list, approvals.get | approvals.approve (T1), approvals.reject (T1) | approvals:read, approvals:decide |
operations | operations.list, operations.get | operations.cancel (T1) | operations:read |
events | events.list, events.stream | none | events:read |
webhooks | webhooks.endpoints.list, webhooks.endpoints.get, webhooks.deliveries.list | webhooks.endpoint.create (T1), webhooks.endpoint.update (T1), webhooks.endpoint.delete (T1), webhooks.endpoint.rotate_secret (T1), webhooks.endpoint.test (T1), webhooks.delivery.replay (T1) | webhooks:manage |
oauth_grants | oauth_grants.list | oauth_grants.revoke (T1) | any credential |
keys | keys.list | keys.create (T2), keys.rotate (T2), keys.revoke (T1) | keys:manage |
memberships | none | memberships.invite (T2), memberships.revoke (T2) | memberships:manage |
execution | execution.jobs.get | execution.requests.propose (T1) | execution:read, execution:propose |
Webhook event types: task.created, task.updated, task.completed, project.stage_advanced, intake.finalized, review.released, membership.invited, membership.revoked, approval.approved, approval.rejected.