@mantyx/sdk 0.9.1 → 0.10.1
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.
- package/CHANGELOG.md +9 -2
- package/README.md +81 -2
- package/dist/a2a-server.cjs.map +1 -1
- package/dist/a2a-server.d.cts +1 -1
- package/dist/a2a-server.d.ts +1 -1
- package/dist/a2a-server.js +1 -1
- package/dist/{chunk-AE7ZSLBH.js → chunk-XMUCELMH.js} +126 -24
- package/dist/chunk-XMUCELMH.js.map +1 -0
- package/dist/{client-BB6cjfsz.d.cts → client-CZUVldDx.d.cts} +401 -3
- package/dist/{client-BB6cjfsz.d.ts → client-CZUVldDx.d.ts} +401 -3
- package/dist/index.cjs +354 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -93
- package/dist/index.d.ts +3 -93
- package/dist/index.js +227 -2
- package/dist/index.js.map +1 -1
- package/docs/agent-runs-protocol.md +123 -113
- package/docs/oauth.md +356 -0
- package/docs/wire-protocol.md +1102 -0
- package/package.json +1 -1
- package/dist/chunk-AE7ZSLBH.js.map +0 -1
|
@@ -66,17 +66,124 @@ All SDK-facing endpoints sit under
|
|
|
66
66
|
/api/v1/workspaces/{workspaceSlug}/...
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
and
|
|
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 <
|
|
73
|
+
Authorization: Bearer <credential>
|
|
73
74
|
# or, equivalently:
|
|
74
|
-
X-API-Key: <
|
|
75
|
+
X-API-Key: <credential>
|
|
75
76
|
```
|
|
76
77
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 +
|
|
847
|
-
|
|
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
|
|
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
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
|
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.
|