@mantyx/sdk 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -66,17 +66,124 @@ All SDK-facing endpoints sit under
66
66
  /api/v1/workspaces/{workspaceSlug}/...
67
67
  ```
68
68
 
69
- and are authenticated with a workspace API key with usage `developer_api`:
69
+ and accept **either** of two bearer credentials interchangeably. The same
70
+ header carries either, so SDKs only need one code path:
70
71
 
71
72
  ```
72
- Authorization: Bearer <api-key>
73
+ Authorization: Bearer <credential>
73
74
  # or, equivalently:
74
- X-API-Key: <api-key>
75
+ X-API-Key: <credential>
75
76
  ```
76
77
 
77
- The workspace slug in the URL must match the key's tenant. Mismatches return
78
- `404 not_found`. Missing/invalid keys return `401 unauthorized`. Rate limits
79
- follow the workspace's existing developer-API sliding-window policy.
78
+ | Credential | Token format | Identifies | Bound to | Use when |
79
+ | ------------------------- | --------------- | ------------------------ | ----------------------- | -------- |
80
+ | **Workspace API key** | `mantyx_…` | The workspace | One workspace, no end-user | Personal scripts, internal automations, anything the SDK caller owns end-to-end. |
81
+ | **OAuth 2.0 access token**| `mantyx_at_…` | An end user **and** the workspace they consented for | One workspace, one user (or one app for `client_credentials`) | "Sign in with MANTYX" apps, third-party integrations, anywhere consent + scopes matter. |
82
+
83
+ The server resolves whichever it sees by token-prefix sniffing (see
84
+ `packages/api/src/services/bearer-credential.ts`) — SDKs do **not** need
85
+ separate code paths or env variables for the two flavours.
86
+
87
+ The workspace slug in the URL must match the credential's tenant.
88
+ Mismatches return `404 not_found` with a `hint` field pointing at the
89
+ correct slug. Missing/invalid credentials return `401 unauthorized`.
90
+ Rate limits follow the workspace's existing developer-API sliding-window
91
+ policy and are tracked per-credential.
92
+
93
+ ### 2.1 Workspace API keys (machine credentials)
94
+
95
+ A workspace admin issues an API key under **Settings → API keys** with
96
+ **Usage = Developer API**. The key inherits two optional restrictions:
97
+
98
+ - **Agent allowlist** (`ApiKey.agentIds`) — empty list = "every
99
+ non-system agent in the workspace"; otherwise only the listed agents
100
+ are visible to `spec.agentId` and ephemeral runs created from the key.
101
+ - **Plan gate** — the workspace tier must include the `apiKeys` feature.
102
+
103
+ API keys carry no granular scopes; possession of a Developer-API key is
104
+ enough to call every route in this document.
105
+
106
+ ### 2.2 OAuth 2.0 access tokens
107
+
108
+ OAuth tokens are a drop-in alternative for the same set of routes, with
109
+ two differences:
110
+
111
+ 1. **Scopes are required.** Each route checks the token carries the
112
+ right scope via `requireScope(...)` and returns
113
+ `403 { "error": "insufficient_scope", "required": "runs:write" }`
114
+ (the value is a string for single-scope routes, an array for
115
+ multi-scope ones — see §2.3). The SDK is expected to surface this
116
+ verbatim. The agent-runs surface uses these scopes:
117
+
118
+ | Endpoint | Required scope |
119
+ | ------------------------------------------------------------ | -------------- |
120
+ | `GET .../models` | `models:read` |
121
+ | `POST .../agent-runs` | `runs:write` |
122
+ | `GET .../agent-runs/{runId}` | `runs:read` |
123
+ | `GET .../agent-runs/{runId}/stream` | `runs:read` |
124
+ | `POST .../agent-runs/{runId}/cancel` | `runs:write` |
125
+ | `POST .../agent-runs/{runId}/tool-results` | `runs:write` |
126
+ | `POST .../agent-sessions` | `sessions:write` |
127
+ | `GET .../agent-sessions/{sessionId}` | `sessions:read` |
128
+ | `DELETE .../agent-sessions/{sessionId}` | `sessions:write` |
129
+ | `POST .../agent-sessions/{sessionId}/messages` | `sessions:write` |
130
+ | `GET /api/oauth/userinfo` | `mantyx.identity:read` |
131
+
132
+ For an SDK that exposes one-shot runs and sessions end-to-end, request
133
+ at minimum `models:read runs:read runs:write sessions:read sessions:write`,
134
+ and add `mantyx.identity:read` if the SDK calls
135
+ `/api/oauth/userinfo` to discover the workspace slug after sign-in.
136
+
137
+ 2. **Tokens are workspace-scoped.** An access token is minted for one
138
+ workspace (chosen by the user at consent time for public apps, or the
139
+ registering workspace for private apps). Calling
140
+ `/api/v1/workspaces/{otherSlug}/...` with such a token returns
141
+ `404 not_found` plus a `hint` with the correct slug.
142
+
143
+ OAuth tokens **also** honor the per-token agent allow-list
144
+ (`OAuthAccessToken.agentIds`) the user picked at consent time — see
145
+ [`docs/oauth.md`](./oauth.md) for the full registration / authorization-code
146
+ + PKCE flow. PKCE (`S256`) is mandatory and every MANTYX OAuth app is a
147
+ confidential client, so the token endpoint requires both `client_secret`
148
+ and `code_verifier`.
149
+
150
+ **Token lifetimes.** Access tokens live **1 hour** (`expires_in: 3600`).
151
+ Refresh tokens are **persistent and non-rotating**: they have no
152
+ time-based expiry and `grant_type=refresh_token` returns the **same**
153
+ refresh token the SDK already holds while minting a brand-new short-lived
154
+ access token. Multiple processes may refresh concurrently using the same
155
+ refresh token without invalidating each other. Refresh tokens stop
156
+ working only when the application access is revoked (`/oauth/revoke`,
157
+ `DELETE /api/oauth/grants/:id`, or app deletion).
158
+
159
+ > **SDK guidance.** Persist the refresh token at first sign-in, treat it
160
+ > as long-lived, and keep refreshing the access token off it on demand
161
+ > (e.g. ~5 minutes before `expires_in` runs out, or lazily on the first
162
+ > `401`). Do **not** rotate or replace the refresh token after each
163
+ > refresh — the value is stable.
164
+
165
+ A single SDK call site looks identical regardless of credential:
166
+
167
+ ```http
168
+ POST /api/v1/workspaces/acme/agent-runs HTTP/1.1
169
+ Authorization: Bearer mantyx_at_… # OAuth access token
170
+ # — or —
171
+ Authorization: Bearer mantyx_… # workspace API key
172
+ Content-Type: application/json
173
+
174
+ { "modelId": "openai:gpt-5.5", "prompt": "...", "tools": [...] }
175
+ ```
176
+
177
+ ### 2.3 Error model for credentials
178
+
179
+ | Status | Body shape | When |
180
+ | ------ | ------------------------------------------------------------------------------------- | ---- |
181
+ | `401` | `{ "error": "Unauthorized", "message": "API key or OAuth access token required..." }` | No `Authorization` / `X-API-Key` header. |
182
+ | `401` | `{ "error": "Invalid API key or OAuth access token" }` | Token doesn't match a row, expired, or revoked. |
183
+ | `403` | `{ "error": "This API key is not for the Developer API", "hint": "..." }` | API key has wrong `usage`. |
184
+ | `403` | `{ "error": "Workspace API keys are not available on this plan.", "code": "api_keys_plan" }` <br> `{ "error": "OAuth applications are not available on this plan.", "code": "oauth_apps_plan" }` | Workspace tier lacks the `apiKeys` / `oauthApps` feature. |
185
+ | `403` | `{ "error": "insufficient_scope", "required": "runs:write" }` (or an array if a route needs multiple) | OAuth token is missing a scope a route demands. The response also sets `WWW-Authenticate: Bearer error="insufficient_scope", scope="..."`. |
186
+ | `404` | `{ "error": "Workspace path does not match this credential", "hint": "..." }` | URL slug ≠ token's workspace. |
80
187
 
81
188
  ## 3. Models
82
189
 
@@ -843,21 +950,8 @@ data: <utf-8 JSON>
843
950
  // Gemini `includeThoughts`, OpenAI `reasoning_content` on reasoning models).
844
951
  { "seq": 2, "type": "thinking_delta", "data": { "text": "First, I should…" } }
845
952
 
846
- // completed assistant message (text + optional tool calls about to execute).
847
- // `turn` is the 0-based tool-turn index this message closes.
848
- // `finishReason` is the canonical lowercase stop reason normalized across
849
- // providers (`"end_turn"`, `"tool_use"`, `"max_tokens"`, `"refusal"`,
850
- // `"malformed_function_call"`, …); `null` / omitted when the provider did
851
- // not report one. `toolCalls` is omitted when the model called no tools.
852
- { "seq": 3, "type": "assistant_message",
853
- "data": {
854
- "text": "...",
855
- "turn": 0,
856
- "finishReason": "tool_use",
857
- "toolCalls": [
858
- { "id": "call_abc", "name": "search", "input": { /* JSON-Schema-matching args */ } }
859
- ]
860
- } }
953
+ // completed assistant message (text + any tool calls about to execute)
954
+ { "seq": 3, "type": "assistant_message", "data": { "text": "...", "toolCalls": [...] } }
861
955
 
862
956
  // server-side tool call/result (informational; SDK does not act on these)
863
957
  { "seq": 4, "type": "tool_call", "data": { "toolUseId": "...", "name": "...", "input": {...} } }
@@ -884,70 +978,18 @@ data: <utf-8 JSON>
884
978
  // is observability so SDK clients can render "memory budget exhausted" status notes.
885
979
  { "seq": 7, "type": "tool_budget_exceeded", "data": { "tool": "recall", "maxCalls": 4, "callIndex": 5 } }
886
980
 
887
- // terminal event — exactly one of `result`, `error`, or `cancelled` lands per run.
981
+ // terminal event
888
982
  { "seq": 8, "type": "result", "data": { "subtype": "success", "text": "Final reply" } }
889
983
  { "seq": 8, "type": "result", "data": { "subtype": "error_local_tool_timeout", "error": "..." } }
890
- { "seq": 8, "type": "error", "data": {
891
- "error": "Model output was truncated (stop_reason=max_tokens). …",
892
- "code": "truncation",
893
- "errorClass": "truncation",
894
- "finishReason": "max_tokens",
895
- "partialText": "{\n \"answer\":… (truncated JSON) …",
896
- "retryable": false
897
- } }
898
984
  { "seq": 8, "type": "cancelled", "data": {} }
899
985
  ```
900
986
 
901
- A run terminates with exactly one of `result`, `error`, or `cancelled`. The
902
- connection is closed by the server immediately after sending the terminal
903
- event. Clients should not assume any particular ordering between the
904
- human-readable `event:` field and the parsed `type` inside `data` — they
905
- are always equal, but implementations should rely on `data.type` because
906
- some HTTP middleware strips the `event:` line.
907
-
908
- **`error` event payload fields.** The runner enriches the `error` event
909
- with structured triage attributes when the failure carried a salvage
910
- path (typically truncation, upstream deadline, or max-budget-with-text):
911
-
912
- | Field | Type | Required | Notes |
913
- | -------------- | -------- | -------- | ----- |
914
- | `error` | string | yes | Human-readable message (also persisted on the run row's `error` column). |
915
- | `code` | string | yes | Legacy alias for `errorClass`. Equals `errorClass` when present; otherwise a small lowercase token (`"error"`, `"invalid_spec"`, `"worker_error"`, …) the SDK can switch on. |
916
- | `errorClass` | string | no | Canonical category. One of `"rate_limit"`, `"overloaded"`, `"server"`, `"context_window"` (input too big), `"truncation"` (output budget exhausted), `"invalid_request"`, `"auth"`, `"timeout"`, `"local_timeout"`, `"upstream_deadline"`, `"unknown"`. New categories may land additively. |
917
- | `finishReason` | string \| null | no | Canonical lowercase stop reason normalized across providers (`"max_tokens"`, `"refusal"`, `"malformed_function_call"`, …). When present, mirrors the value on the last `assistant_message`. |
918
- | `partialText` | string | no | **Best-effort raw bytes** the model emitted before the failure. For `outputSchema` runs this is likely **incomplete JSON** that will fail `JSON.parse` — see §4.5 / `docs/wire-protocol.md` §7. Also persisted on the run row's `finalText` column so the Calls UI can render it alongside a truncation banner. |
919
- | `retryable` | boolean | no | Coarse retry hint inherited from the pipeline's error classifier. Informational; the SDK still owns the actual retry decision. |
920
-
921
- **Truncation contract.** When the model is mid-output and Gemini /
922
- Anthropic / OpenAI hit the output budget, MANTYX does **not** discard
923
- the bytes that already streamed. Instead:
924
-
925
- 1. The last `assistant_message` for the turn carries the partial text
926
- plus `finishReason: "max_tokens"`.
927
- 2. The terminal SSE event is an `error` (not `result`) with
928
- `errorClass: "truncation"` and `data.partialText` set to the same
929
- bytes.
930
- 3. The run row exposed by `GET /agent-runs/:runId` has
931
- `{ status: "failed", finalText: "<partial text>",
932
- error: "Model output was truncated …", failureReason: { errorClass:
933
- "truncation", finishReason: "max_tokens" } }`.
934
-
935
- `partialText` is a **best-effort raw byte sequence** — for `outputSchema`
936
- runs it will almost always fail `JSON.parse` because the JSON object was
937
- not closed. SDKs should treat it as diagnostic data, never as a
938
- schema-conformant reply. Surfacing it (as a "truncated reply — JSON
939
- likely incomplete" status note) is the recommended pattern; silently
940
- falling back to it as the answer is not.
941
-
942
- **Run snapshot fields.** `GET /agent-runs/:runId` returns the run row
943
- with these triage-relevant columns:
944
-
945
- | Field | Notes |
946
- | --------------- | ----- |
947
- | `status` | `"queued" \| "running" \| "succeeded" \| "failed" \| "cancelled"`. |
948
- | `finalText` | Final assistant text on success; same string as terminal `data.partialText` when `failureReason.errorClass === "truncation"`. Otherwise `null`. |
949
- | `error` | Human-readable error message (matches terminal `error.data.error`). `null` on success / cancellation. |
950
- | `failureReason` | JSON object `{ errorClass, finishReason }` on `status === "failed"` runs that carried a salvage payload. Future-proof for additional triage fields. `null` otherwise. |
987
+ A run terminates with exactly one of `result` or `cancelled`. The connection
988
+ is closed by the server immediately after sending the terminal event. Clients
989
+ should not assume any particular ordering between the human-readable `event:`
990
+ field and the parsed `type` inside `data` — they are always equal, but
991
+ implementations should rely on `data.type` because some HTTP middleware
992
+ strips the `event:` line.
951
993
 
952
994
  ## 8. Local tool result
953
995
 
@@ -1003,32 +1045,6 @@ Common codes:
1003
1045
  | `run_terminal` | 409 | Tool-result after run finished |
1004
1046
  | `rate_limited` | 429 | Per-API-key sliding window |
1005
1047
 
1006
- **Run-level error categories.** When a run terminates via the SSE `error`
1007
- event (§7), the payload carries an `errorClass` triage category in
1008
- addition to the human-readable `error` message. SDKs typically expose
1009
- this as a typed field on their run-error type (TS `MantyxRunError.errorClass`,
1010
- Python `MantyxRunError.error_class`, Go `RunError.ErrorClass`). The
1011
- canonical set:
1012
-
1013
- | `errorClass` | Typical cause | Has `partialText`? |
1014
- | ------------------- | ------------- | ------------------ |
1015
- | `rate_limit` | Provider rate-limited the request (HTTP 429-equivalent). | No |
1016
- | `overloaded` | Provider returned a transient "overloaded" / 5xx. | No |
1017
- | `server` | Generic upstream provider error. | No |
1018
- | `context_window` | Input exceeded the model's context window. | No |
1019
- | `truncation` | Output budget exhausted mid-reply (`finishReason: "max_tokens"`). | **Yes** |
1020
- | `invalid_request` | Provider rejected the spec / params. | No |
1021
- | `auth` | BYOK credentials invalid for this run. | No |
1022
- | `timeout` | Generic upstream timeout (provider-side). | No |
1023
- | `local_timeout` | SDK didn't POST a `tool-result` within `localToolTimeoutMs`. | No |
1024
- | `upstream_deadline` | MANTYX worker deadline exceeded waiting on the provider. | Sometimes |
1025
- | `unknown` | Anything else — fallback so SDKs always have a category. | No |
1026
-
1027
- The category set is **additive over the wire**: new categories may
1028
- appear without bumping the protocol version, so SDKs should default to
1029
- `unknown` (or simply pass the raw string through to callers) for
1030
- unrecognized values rather than crashing.
1031
-
1032
1048
  ## 11. Suggested client architecture
1033
1049
 
1034
1050
  A reference SDK should:
@@ -1084,13 +1100,7 @@ A reference SDK should:
1084
1100
  the event to the caller (status banner, log line, telemetry). Do
1085
1101
  **not** abort the run on these events; the run continues through
1086
1102
  `result` / `error` / `cancelled` as usual.
1087
- - On terminal `result` with `subtype === "success"`, resolve the call
1088
- with the final `text`. On a terminal `error` event, raise a typed
1089
- run-error that carries the new triage attributes (`errorClass`,
1090
- `finishReason`, `partialText`, `retryable`) so callers can render
1091
- "truncated reply — JSON likely incomplete" banners and short-circuit
1092
- retry policies. Treat `partialText` as **diagnostic** data — never
1093
- auto-fall-back to it as the final answer.
1103
+ - On terminal `result`, resolve the call. On `error` subtype, throw.
1094
1104
  4. Re-emit assistant deltas/events as a stream/iterator for callers who care
1095
1105
  about live output.
1096
1106
  5. Treat the protocol as the contract. Implementation details such as Valkey
package/docs/oauth.md ADDED
@@ -0,0 +1,356 @@
1
+ # OAuth 2.0 in MANTYX
2
+
3
+ MANTYX exposes an OAuth 2.0 authorization server at **`/api/oauth/...`** that
4
+ issues access tokens accepted on every existing API surface
5
+ (`/api/v1`, `/api/a2a`, `/mcp`) plus a small identity endpoint
6
+ (`/api/oauth/userinfo`).
7
+
8
+ OAuth tokens are a **drop-in alternative to workspace API keys** — same
9
+ HTTP contract, same `Authorization: Bearer …` header, same per-agent
10
+ allowlist semantics. The only thing that changes is that the token
11
+ carries **scopes**: per-route permissions you grant at consent time
12
+ instead of the coarse `ApiKeyUsage` (`mcp` | `developer_api` | `a2a`)
13
+ on classic workspace API keys.
14
+
15
+ > See `architecture.md` for the full request pipeline and where the
16
+ > bearer resolver sits in it.
17
+
18
+ ## When to use OAuth vs. an API key
19
+
20
+ * **Personal scripts and internal tools you control end-to-end** — keep
21
+ using a workspace API key. It's one click to issue, one header to set.
22
+ * **Apps that other people sign in to** — register an OAuth application
23
+ and run the Authorization Code + PKCE flow. End users approve specific
24
+ scopes for a specific workspace. Two visibility modes:
25
+ * **Private** — locked to the workspace that registered the app. Only
26
+ members of that workspace can authorize. Optionally enable
27
+ `client_credentials` for unattended machine-to-machine traffic.
28
+ * **Public** — any user can authorize the app and pick the workspace
29
+ they want to grant access to on the consent screen.
30
+
31
+ ## High-level flow
32
+
33
+ ```mermaid
34
+ sequenceDiagram
35
+ participant App as Your app
36
+ participant User as End user
37
+ participant Web as MANTYX SPA
38
+ participant API as MANTYX API
39
+ App->>User: Open /oauth/authorize?...
40
+ User->>Web: Sign in (if needed) and review scopes
41
+ Web->>API: POST /api/oauth/authorize/decide (approve)
42
+ API->>App: 302 redirect_uri?code=…&state=…
43
+ App->>API: POST /api/oauth/token (code + verifier)
44
+ API->>App: { access_token, refresh_token, scope, expires_in }
45
+ App->>API: GET /api/v1/workspaces/{slug}/agents (Bearer token)
46
+ API->>App: 200 OK (scope check passes)
47
+ ```
48
+
49
+ ## Registering an application
50
+
51
+ Open **Developer → OAuth apps** (workspace admins only). Both private and
52
+ public apps are registered from this page; the Visibility radio decides
53
+ which flow you get.
54
+
55
+ Provide:
56
+
57
+ * **Name** and **description** (shown on the consent screen).
58
+ * **Logo URL** (optional).
59
+ * **Visibility** — **Private** locks tokens to this workspace; **Public**
60
+ lets any signed-in user pick a workspace at consent time.
61
+ * **Redirect URIs** — at least one. Allowed schemes:
62
+ * `https://…`
63
+ * `http://localhost`, `http://127.0.0.1`, or `http://[::1]` (any port,
64
+ any path)
65
+ * Custom schemes for native apps, e.g. `myapp://callback`.
66
+ * **Allowed scopes** — only scopes you check here can be requested by
67
+ the application at consent time.
68
+ * **Client secret** — every MANTYX OAuth app is a **confidential
69
+ client**. The `client_secret` is returned **once** on creation; the
70
+ `/token`, `/revoke`, and `/introspect` endpoints all require the
71
+ matching value. We do not support PKCE-only public-client
72
+ registrations — visibility (private vs. public) only controls *who*
73
+ can authorize the app, not whether the app keeps a secret. PKCE is
74
+ still mandatory on top of the secret for defense in depth (see
75
+ below).
76
+ * **Allow `client_credentials` grant** *(private apps only)* — for
77
+ unattended machine-to-machine use; not available on public apps
78
+ because the token has to be bound to a single workspace at mint time.
79
+
80
+ The `client_id` is `mantyx_oa_<id>`. Confidential client secrets are
81
+ `mantyx_oas_<secret>`.
82
+
83
+ OAuth applications require the **`oauthApps`** feature on the registering
84
+ workspace's tier (mirrors the existing `apiKeys` plan check). For public
85
+ apps the same gate is also applied to the workspace each end user picks
86
+ at consent time, so a free workspace can't host paid features through
87
+ a public app authorized for it.
88
+
89
+ ## Authorization Code + PKCE (browser, native, server-side)
90
+
91
+ Every grant carries **two** client-binding factors:
92
+
93
+ * `client_secret` — proves the registered client made the call (every
94
+ MANTYX OAuth app is confidential, so this is always required).
95
+ * PKCE `code_verifier` — proves the same browser session that started
96
+ `/authorize` is finishing the exchange. We accept only `S256` and
97
+ reject any token request without a verifier.
98
+
99
+ 1. Generate a high-entropy `code_verifier` (43–128 chars, RFC 7636).
100
+ 2. Compute `code_challenge = base64url(sha256(code_verifier))` (no
101
+ padding).
102
+ 3. Send the user to:
103
+
104
+ ```text
105
+ GET /api/oauth/authorize
106
+ ?client_id=mantyx_oa_…
107
+ &redirect_uri=<exact registered URI>
108
+ &response_type=code
109
+ &scope=mantyx.identity:read+agents:read+runs:write
110
+ &state=<random per-session token>
111
+ &code_challenge=<S256 challenge>
112
+ &code_challenge_method=S256
113
+ ```
114
+
115
+ The MANTYX SPA at `/oauth/authorize` reads the same query, asks the
116
+ user to log in if needed, lets them pick the workspace (third-party
117
+ apps), pick the agent allow-list (when any of `agents:invoke`,
118
+ `runs:write`, `a2a:invoke`, `mcp:connect` are requested), and then
119
+ approves or denies.
120
+
121
+ 4. On approve we redirect to:
122
+
123
+ ```text
124
+ <redirect_uri>?code=<auth code>&state=<your state>
125
+ ```
126
+
127
+ On deny:
128
+
129
+ ```text
130
+ <redirect_uri>?error=access_denied&state=<your state>
131
+ ```
132
+
133
+ 5. Exchange the code:
134
+
135
+ ```http
136
+ POST /api/oauth/token
137
+ Content-Type: application/x-www-form-urlencoded
138
+
139
+ grant_type=authorization_code
140
+ &code=…
141
+ &redirect_uri=<exact same URI>
142
+ &client_id=mantyx_oa_…
143
+ &client_secret=mantyx_oas_…
144
+ &code_verifier=<original verifier>
145
+ ```
146
+
147
+ Response:
148
+
149
+ ```json
150
+ {
151
+ "access_token": "mantyx_at_…",
152
+ "token_type": "Bearer",
153
+ "expires_in": 3600,
154
+ "refresh_token": "mantyx_rt_…",
155
+ "scope": "mantyx.identity:read agents:read runs:write"
156
+ }
157
+ ```
158
+
159
+ 6. Use the access token like any other workspace bearer:
160
+
161
+ ```http
162
+ GET /api/v1/workspaces/<slug>/agents
163
+ Authorization: Bearer mantyx_at_…
164
+ ```
165
+
166
+ `<slug>` must be the workspace the consent was for. OAuth tokens
167
+ issued for workspace A return **403** on `/api/v1/workspaces/B/...`.
168
+
169
+ 7. **Token lifetimes.**
170
+
171
+ * Access tokens live **1 hour** (`expires_in: 3600`).
172
+ * Refresh tokens are **persistent and non-rotating**: they never
173
+ time-expire. They stop working only when the application access
174
+ is explicitly revoked via `/oauth/revoke` (with the refresh
175
+ token), `DELETE /api/oauth/grants/:id`, or deletion of the
176
+ OAuth application itself.
177
+ * Calling `grant_type=refresh_token` mints a brand-new short-lived
178
+ access token and **echoes back the same refresh token** the
179
+ client already holds. The previous access tokens are **not**
180
+ revoked — multiple backend workers can mint live access tokens
181
+ off a shared refresh without invalidating each other's chains.
182
+
183
+ ```http
184
+ POST /api/oauth/token
185
+
186
+ grant_type=refresh_token
187
+ &refresh_token=mantyx_rt_…
188
+ &client_id=mantyx_oa_…
189
+ &client_secret=mantyx_oas_…
190
+ &scope=runs:write # optional narrowing (must be a subset)
191
+ ```
192
+
193
+ ```json
194
+ {
195
+ "access_token": "mantyx_at_…",
196
+ "token_type": "Bearer",
197
+ "expires_in": 3600,
198
+ "refresh_token": "<same value the client just sent>",
199
+ "scope": "runs:write"
200
+ }
201
+ ```
202
+
203
+ Clients should persist the refresh token once at first sign-in
204
+ (treat it as long-lived) and only refresh the access token from
205
+ it as needed.
206
+
207
+ 8. **Revoke (RFC 7009).**
208
+
209
+ ```http
210
+ POST /api/oauth/revoke
211
+
212
+ token=<access or refresh token>
213
+ &client_id=mantyx_oa_…
214
+ &client_secret=mantyx_oas_…
215
+ ```
216
+
217
+ Always returns `200`, even when the token is unknown — by design.
218
+
219
+ * Revoking an **access token** kills only that single access
220
+ token. Other access tokens minted from the same refresh keep
221
+ working until they expire (or until the refresh is revoked).
222
+ * Revoking a **refresh token** kills the refresh and *every* live
223
+ access token tied to its grant in one shot.
224
+
225
+ ## Client credentials (private workspace apps, machine-to-machine)
226
+
227
+ Private workspace applications with `allowsClientCredentials: true` can
228
+ request a token without a user:
229
+
230
+ ```http
231
+ POST /api/oauth/token
232
+
233
+ grant_type=client_credentials
234
+ &client_id=mantyx_oa_…
235
+ &client_secret=mantyx_oas_…
236
+ &scope=runs:write+agents:invoke # optional, must be a subset of allowedScopes
237
+ ```
238
+
239
+ The token's `tenantId` is the application's owning workspace and its
240
+ agent allow-list defaults to every non-system agent in that workspace
241
+ (no end-user consent screen). Use this for cron jobs, internal services
242
+ and partner integrations where there is no end user.
243
+
244
+ ## "Sign in with MANTYX"
245
+
246
+ There is **no OIDC** today. The access token is enough:
247
+
248
+ 1. Run the auth-code + PKCE flow with `scope=mantyx.identity:read`.
249
+ 2. Call:
250
+
251
+ ```http
252
+ GET /api/oauth/userinfo
253
+ Authorization: Bearer mantyx_at_…
254
+ ```
255
+
256
+ Response:
257
+
258
+ ```json
259
+ {
260
+ "sub": "<user id>",
261
+ "email": "user@example.com",
262
+ "workspace": { "id": "…", "slug": "…", "name": "…" }
263
+ }
264
+ ```
265
+
266
+ `/api/auth/me` continues to accept the user's web JWT as before; both
267
+ paths can be used to bootstrap session info for "Sign in with MANTYX"
268
+ clients.
269
+
270
+ ## Scope catalog
271
+
272
+ Defined in `packages/api/src/oauth/scopes.ts` and mirrored by the SPA
273
+ in `packages/web/src/lib/oauthScopes.ts`. The catalog is also expressed
274
+ in the OpenAPI spec at `packages/api/openapi/developer-v1.yaml`.
275
+
276
+ | Scope | Purpose |
277
+ | --- | --- |
278
+ | `mantyx.identity:read` | `/api/oauth/userinfo` and `/api/auth/me`. |
279
+ | `agents:read` | `GET /api/v1/.../agents`. |
280
+ | `agents:write` | Reserved for future agent CRUD on the Developer API. |
281
+ | `agents:invoke` | Run an agent — required by ephemeral runs, agent sessions, A2A invoke. |
282
+ | `sessions:read` / `sessions:write` | Ephemeral SDK agent sessions. |
283
+ | `runs:read` / `runs:write` | Read run snapshots and SSE streams; start, cancel, submit tool-results. |
284
+ | `models:read` | `GET /api/v1/.../models`. |
285
+ | `tools:read` / `tools:write` | List/manage workspace tools. |
286
+ | `schedules:read` / `schedules:write` | List/manage cron schedules and trigger them manually. |
287
+ | `inbounds:read` / `inbounds:write` | List/manage inbound webhooks/email configs. |
288
+ | `plugins:read` | List installed plugins for the workspace. |
289
+ | `hive:read` / `hive:write` | Workspace Hive objects. |
290
+ | `a2a:discovery` | `GET /api/a2a/{slug}/discovery`. |
291
+ | `a2a:invoke` | Send Agent2Agent JSON-RPC requests (also requires `agents:invoke`). |
292
+ | `mcp:connect` | Open MCP Streamable HTTP sessions. |
293
+
294
+ `runs:write`, `agents:invoke`, `a2a:invoke`, and `mcp:connect` participate
295
+ in the **agent allow-list** that the consent screen surfaces. An empty
296
+ list expands to "every non-system agent in the workspace" at request
297
+ time — same semantics as today's `WorkspaceApiKey.agentIds`.
298
+
299
+ ## Redirect URI rules
300
+
301
+ * Exact-match comparison (case-sensitive scheme/host, fragment-stripped).
302
+ Trailing slashes are significant.
303
+ * Loopback HTTP is allowed without TLS for localhost development.
304
+ * Custom schemes are allowed for native apps; pick a scheme you control
305
+ (e.g. `com.example.myapp://callback`).
306
+ * `redirect_uri` on `/api/oauth/token` must equal the value used at
307
+ `/api/oauth/authorize`.
308
+
309
+ ## Error model
310
+
311
+ | Where | Body |
312
+ | --- | --- |
313
+ | `/authorize` query validation | `{ "error": "Invalid authorize request", "details": {...} }` |
314
+ | Unknown / unauthorized client | `401 { "error": "invalid_client" }` |
315
+ | PKCE failure, expired/used code, redirect mismatch | `400 { "error": "invalid_grant" }` |
316
+ | Insufficient scope on a Developer API call | `403 { "error": "insufficient_scope", "required": ["..."] }` |
317
+ | Wrong workspace in URL | `403 { "error": "wrong_workspace", "correctSlug": "..." }` |
318
+ | Plan does not include OAuth apps | `403 { "error": "...", "code": "oauth_apps_plan" }` |
319
+
320
+ ## Token format
321
+
322
+ * Access tokens: `mantyx_at_<32-byte url-safe random>`.
323
+ * Refresh tokens: `mantyx_rt_<32-byte url-safe random>`.
324
+ * Client ids: `mantyx_oa_<id>`.
325
+ * Client secrets: `mantyx_oas_<secret>`.
326
+
327
+ Stored as **SHA-256 with HMAC** (rate-friendly), with a 12-character
328
+ prefix index for fast lookups (mirrors today's
329
+ `WorkspaceApiKey.keyPrefix`).
330
+
331
+ ## Token lifetimes & lifecycle
332
+
333
+ | Token | Lifetime | How it ends |
334
+ | --- | --- | --- |
335
+ | **Access token** | 1 hour (`expires_in: 3600`). | Time-expires; or revoked via `/oauth/revoke`, refresh-token revocation, grant deletion, or app deletion. |
336
+ | **Refresh token** | **No time-based expiry** — persistent. | Revoked via `/oauth/revoke` (refresh token), `DELETE /api/oauth/grants/:id` (user "Revoke access" action), or deletion of the OAuth application. |
337
+ | **Authorization code** | 10 minutes, single-use. | Consumed by `/oauth/token` (auth-code grant) or expires. |
338
+
339
+ Refresh tokens are **non-rotating**. Calling
340
+ `grant_type=refresh_token` issues a new short-lived access token but
341
+ returns the **same refresh token** the client already holds. Multiple
342
+ backend workers may refresh concurrently using the same shared
343
+ refresh token without invalidating each other's chains.
344
+
345
+ This makes refresh tokens the long-lived authorization-of-record:
346
+ clients should persist them once at first sign-in (encrypted at rest)
347
+ and treat the refresh value as the single trust anchor for the grant.
348
+
349
+ ## See also
350
+
351
+ * `packages/api/src/routes/oauth.ts` — authorization server endpoints.
352
+ * `packages/api/src/services/bearer-credential.ts` — unified resolver
353
+ for API keys and OAuth tokens.
354
+ * `packages/api/src/middleware/oauth-scope.ts` — `requireScope(...)`.
355
+ * `packages/api/openapi/developer-v1.yaml` — `securitySchemes.oauth2`
356
+ and per-operation scope lists.