# Delegated OpenAPI Sessions

This guide explains how delegated sessions work in Quake Platform, what they are for, and how to connect an external system to them.

Use delegated sessions when an external system should call Quake Platform **as a signed-in Quake user**, not as an app-only integration.

## Choose a flow and host

| Use case | Flow | Identity and visibility |
| --- | --- | --- |
| Server integration operating across a dataset | `client_credentials` at `/oauth/token` | OAuth client; dataset-wide record visibility within granted scopes |
| Integration acting for a signed-in user | Authorize, then exchange at `/oauth/session` | Dataset user; user permissions and record visibility also apply |
| External agent chat UI | Delegated session | Requires `agentChats:write`; tools need their own scopes and user permissions |

Production hosts are distinct:

| Purpose | URL |
| --- | --- |
| Interactive login / authorization | `https://app.quake.dev/api/openapi/authorize` |
| Token exchange and refresh | `https://api.quake.dev/oauth/token`, `https://api.quake.dev/oauth/session`, `https://api.quake.dev/oauth/session/refresh` |
| Delegated session summary | `https://api.quake.dev/session` |
| REST resources | `https://api.quake.dev/core/...` |
| External agent chat stream | `https://api.quake.dev/stream-api/v1/agent-chat` |
| API reference | [Core API reference](https://api.quake.dev/documentation) |

Use hosts from the same environment throughout the flow. For local development, check the configured dashboard, Core API and Streams URLs rather than mixing production credentials with localhost.

## App-only quickstart

Create an active OAuth client for the target dataset and allow `clients:read`. From a trusted server, request only the scopes this integration needs:

```bash
curl --fail-with-body 'https://api.quake.dev/oauth/token' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=client_credentials' \
  --data-urlencode "client_id=$QUAKE_CLIENT_ID" \
  --data-urlencode "client_secret=$QUAKE_CLIENT_SECRET" \
  --data-urlencode 'scope=clients:read'
```

The response contains `access_token`, `token_type: "Bearer"`, `expires_in` and a space-separated `scope`. It has no delegated `session` or refresh token. Obtain another app token when it expires. Omitting `scope` requests all configured client scopes; an explicit scope is preferable for limited integrations. `clients` means recruitment companies, not OAuth client registrations.

Set `QUAKE_ACCESS_TOKEN` to the returned access token, then make a read:

```bash
curl --fail-with-body 'https://api.quake.dev/core/clients?limit=10&offset=0' \
  -H "Authorization: Bearer $QUAKE_ACCESS_TOKEN"
```

Keep client secrets on the server. For browser/native integrations, use the delegated PKCE flow below.

## What a delegated session is

A delegated session is a user-scoped OpenAPI session created from an OAuth authorization code.

It returns:

- an `access_token`
- a `refresh_token`
- an `expires_in` value
- a `session` object with the signed-in user, dataset, granted scopes, client, and MFA summary

This is different from the normal app-only `client_credentials` flow.

- `client_credentials` acts as the OAuth client itself
- delegated session acts as a real Quake user inside the client's dataset

## When to use it

Use delegated sessions when the external system needs Quake Platform to enforce:

- the signed-in user's dataset membership
- the signed-in user's role permissions
- the OAuth client's allowed scopes

Common examples:

- embedded partner workflows that need the current Quake user
- browser-based integrations
- native apps
- server integrations that need to know which Quake user approved the action

## What is required

Before integrating, you need:

- an active OAuth client in Quake Platform
- the client's `client_id`
- the client's configured `redirect_uri`
- at least one allowed scope on the client
- a Quake user who belongs to the same dataset as that OAuth client

You must also choose one client authentication mode:

- **Confidential client**
  Send `client_secret` during session exchange and refresh. This fits server-side systems that can keep secrets.
- **Public client / PKCE client**
  Do not send `client_secret`. Send `code_challenge` on authorize and `code_verifier` on exchange. This fits browser, extension, and native-app flows.

Use the same mode consistently. Refresh enforces the same client auth method used when the delegated session was created.

## Dataset rule

Each OAuth client belongs to exactly one dataset.

Delegated sessions are always tied to that dataset. During the dashboard login flow, if the user has not selected a dataset yet, Quake Platform redirects them into dataset selection and auto-selects the dataset owned by the `client_id`.

If the user does not belong to that dataset, delegated session creation fails.

## Mental model

Delegated session scope is the intersection of three things:

1. the scopes requested during authorization
2. the scopes allowed on the OAuth client
3. the signed-in user's dataset role permissions

That means a client asking for `clients:write` still does not get write access unless the user's dataset role also allows it.

## Endpoints used in the flow

Main endpoints:

- dashboard authorize entrypoint: `/api/openapi/authorize`
- delegated session exchange: `/oauth/session`
- delegated session refresh: `/oauth/session/refresh`
- delegated session introspection: `/session`

Related but different:

- `/oauth/token` is for app tokens and standard OAuth token exchange. Delegated session bootstrap specifically happens on `/oauth/session`.

## Delegated quickstart

1. Register the exact callback URI on the client and allow `clients:read`.
2. Generate an unpredictable `state` and, for a public client, a PKCE verifier and its base64url-encoded SHA-256 challenge. Keep the verifier and state until the callback.
3. Redirect to the production authorization URL shown below. Use your actual registered callback, challenge, and state.
4. On callback, verify the returned `state` matches before exchanging the code. Exchange each code once, using the same callback URI and PKCE verifier (or the confidential client secret).
5. Use the returned bearer token for REST calls and retain the rotated refresh token securely.

The examples below show request shapes; replace illustrative values before use. For an executable confidential-client exchange after callback:

```bash
curl --fail-with-body 'https://api.quake.dev/oauth/session' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode "client_id=$QUAKE_CLIENT_ID" \
  --data-urlencode "client_secret=$QUAKE_CLIENT_SECRET" \
  --data-urlencode "code=$QUAKE_AUTHORIZATION_CODE" \
  --data-urlencode "redirect_uri=$QUAKE_REDIRECT_URI"
```

Public clients omit `client_secret` and send `code_verifier` instead. The endpoint also accepts the JSON bodies below.

## End-to-end flow

### 1. Send the user to Quake authorization

Redirect the user to:

```text
/api/openapi/authorize
```

Required query fields:

- `client_id`
- `response_type=code`
- `redirect_uri`

Recommended query fields:

- `scope`
- `state`

For PKCE clients also send:

- `code_challenge`
- `code_challenge_method=S256`

Example:

```text
https://app.quake.dev/api/openapi/authorize?client_id=d-...&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&response_type=code&scope=clients%3Aread&state=abc123&code_challenge=...&code_challenge_method=S256
```

What Quake Platform does next:

- if the user is not logged in, it sends them through Quake login
- if the user is logged in but has not selected a dataset, it selects the dataset for that client
- if MFA is pending, it routes through MFA first
- after login is complete, Quake redirects back to your `redirect_uri` with an authorization `code`

## 2. Exchange the authorization code for a delegated session

POST to the Core API host:

```text
https://api.quake.dev/oauth/session
```

JSON body for a confidential client:

```json
{
  "client_id": "d-...",
  "client_secret": "ocs-...",
  "code": "eyJ...",
  "redirect_uri": "https://example.com/callback"
}
```

JSON body for a PKCE client:

```json
{
  "client_id": "d-...",
  "code": "eyJ...",
  "redirect_uri": "https://example.com/callback",
  "code_verifier": "your-original-pkce-verifier"
}
```

Successful response shape:

```json
{
  "access_token": "eyJ...",
  "refresh_token": "ors-...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "session": {
    "isLoggedIn": true,
    "authStage": "authenticated",
    "user": {
      "id": "user-id",
      "user_dataset_id": "user-dataset-id",
      "email": "user@example.com",
      "name": "Jane User",
      "image": null,
      "role": "Recruiter",
      "permissions": []
    },
    "dataset": {
      "id": "dataset-id",
      "slug": "acme",
      "name": "Acme"
    },
    "slug": "acme",
    "mfa": {
      "enrolled": true,
      "passkeyCount": 1
    },
    "client": {
      "id": "oauth-client-record-id",
      "client_id": "d-...",
      "kind": "db",
      "name": "Acme partner app"
    },
    "granted_scopes": ["clients:read"]
  }
}
```

## 3. Call Quake OpenAPI with the delegated access token

Send the access token as a bearer token:

```http
Authorization: Bearer <access_token>
```

Quake Platform then executes `/core/*` requests in the signed-in user's dataset context.

It can also execute delegated external chat requests at:

```text
/stream-api/v1/agent-chat
```

This route returns `text/event-stream` and is intended for external UIs that want to build chat experiences on top of Quake AI agents.

That means:

- dataset scoping uses the OAuth client's dataset
- actor context is the signed-in user
- scope checks still apply
- user membership is re-validated on later requests

For external chat specifically:

- the token must be a delegated user session, not an app-only token
- the OAuth client must have `agentChats:write`
- individual agent tools still respect the delegated session's granted scopes during execution
- list discovery requires `lists:read`; list creation, updates, and membership changes require `lists:write`
- adding or removing list members also requires read scope for each linked record type, such as `candidates:read`

### Scope and visibility recipes

Scopes are space-separated on authorization/token requests and arrays in `session.granted_scopes`. A resource wildcard such as `candidates:*` covers that resource's read/write scopes. Prefer explicit scopes for the operations you actually use.

| Task | Scopes to request | Additional restrictions |
| --- | --- | --- |
| Read recruitment companies | `clients:read` | Delegated users see only visible clients and visible linked contacts |
| Edit candidates | `candidates:write` (also `candidates:read` to read before/after) | Candidate and any submitted references must be visible; owner IDs are dataset-user IDs |
| REST `GET /core/notes` | `notes:read` | This REST route does not assert `tags:read` or linked-record read scopes at its boundary; it does not use the agent tool's record-visibility filtering |
| Agent `search_notes`, filtered to candidates | `agentChats:write notes:read tags:read candidates:read` | Tool also checks note/tag and candidate view permissions and record visibility |
| Agent `search_notes`, filtered to contacts and jobs | `agentChats:write notes:read tags:read contacts:read jobs:read` | All explicitly requested record types need their read scope; results are limited to authorized types |
| List dataset users for owner assignment | `users:read` | `/core/users` returns dataset memberships, including `id` and global `user_id` |
| Change a user's access-group assignments | `users:write access_groups:write` | Both checks apply when access-group fields are supplied |

For `search_notes`, `notes:read` and `tags:read` are always required, even without a tag filter. Requested native record types add `applications:read`, `candidates:read`, `clients:read`, `contacts:read`, or `jobs:read`; a custom record type `CE_<model-id>` adds `customEntities.<model-id>:read`. A search without record-type filters still limits results to types allowed by the user's role and token. A successful REST note read therefore does not prove that the agent tool has sufficient permissions.

Scopes and ownership are separate gates. Delegated lists/counts/search exclude hidden owner-backed records; a filter naming an owner does not grant visibility. Hidden singular record reads return `404`; hidden mutations and newly supplied links return `403`. Admins and users with the applicable `access_groups:bypass` permission retain the defined bypass behavior. App tokens have dataset-wide record visibility within scopes. See [Data models and safe updates](https://api.quake.dev/documentation/guides/data-models-and-safe-updates.md) for relationships, ownership and partial updates.

### User IDs in the session

`session.user.id` is the global account ID (`users.id`). `session.user.user_dataset_id` identifies that account's membership in this dataset (`user_datasets.id`). `/core/users` exposes the membership as `id` and the account as `user_id`. Use the membership ID for `primary_owner`, `owner.id`, secondary owners and access-group assignments. A global account can have different membership IDs and roles in different datasets. Do not infer dataset membership from the existence of a global user ID.

## 4. Refresh the delegated session

POST to:

```text
https://api.quake.dev/oauth/session/refresh
```

Example:

```json
{
  "client_id": "d-...",
  "client_secret": "ocs-...",
  "refresh_token": "ors-..."
}
```

Or for PKCE/public mode:

```json
{
  "client_id": "d-...",
  "refresh_token": "ors-..."
}
```

Refresh behavior:

- returns a new access token
- rotates the refresh token
- invalidates the old refresh token after use
- re-checks the user's dataset membership
- re-checks the client's current scopes
- re-applies user-permission filtering before returning the new session

If scopes were reduced and nothing valid remains, refresh fails.

Serialize refreshes for each session. Persist the newly returned access token and refresh token together before making subsequent requests. Do not let concurrent workers reuse the previous refresh token. Rotation keeps the original refresh-session expiry; it does not start a fresh seven-day session. If a refresh response is lost after the server consumed the token, reusing that token can fail with `invalid_grant`; restart authorization when no usable replacement remains.

Use `expires_in` from each response to schedule refresh before expiry. After a `401`, refresh once if possible and retry a safe read. Before retrying a mutation whose outcome is uncertain, verify its result with the resource's read/status operation and its documented deduplication contract. Refresh is not a guarantee that repeating a write is safe.

## 5. Read the current delegated session

GET:

```text
https://api.quake.dev/session
```

With:

```http
Authorization: Bearer <access_token>
```

This returns the current delegated session summary for the bearer token.

It only works for delegated user sessions. App-only tokens are rejected with `delegated_session_required`.

## What Quake Platform enforces

Delegated sessions are not a one-time permission decision. Quake Platform keeps checking:

- the OAuth client is still active
- the user still belongs to the dataset
- the user's `user_dataset_id` still matches
- the client still matches the dataset
- the remaining scopes are still allowed by both client config and user role permissions

If those checks fail, later calls can start returning `invalid_grant`, `invalid_client`, or `invalid_scope`.

## Token lifetime

Current defaults:

- access token lifetime: 1 hour
- refresh token lifetime: 7 days

The access token lifetime never exceeds the remaining refresh-session lifetime.

## Common errors

Typical errors during exchange or refresh:

- `invalid_client`
  Wrong client credentials, inactive client, or wrong auth method.
- `invalid_request`
  Missing required fields like `redirect_uri`, `code`, `code_challenge`, or `code_verifier`.
- `invalid_grant`
  Wrong redirect URI, wrong code verifier, reused code, reused refresh token, expired session, or membership changed.
- `invalid_scope`
  Requested scopes are not allowed by the client, or the user's permissions no longer allow any of them.
- `delegated_session_required`
  You called `/session` with an app-only token instead of a delegated user token.

### Troubleshooting checklist

| Symptom | What to verify |
| --- | --- |
| Login succeeds but exchange fails | Exact registered `redirect_uri`, unused code, same PKCE verifier, and client auth mode; authorization and exchange must use the same environment |
| `invalid_client` | Client is active; ID/secret match; public flow omits secret and confidential flow includes it |
| `invalid_scope` or missing grants | Requested scopes, client's allowed scopes, and current dataset role permissions; inspect `session.granted_scopes` rather than assuming the request was granted |
| `invalid_grant` on refresh | Session expiry, membership status, and whether another worker already rotated that refresh token |
| Record missing from list or singular read is `404` | Correct dataset and Quake record ID, user ownership/access groups; a read scope alone does not make a hidden record visible |
| Record write/link returns `403` | User can see both edited and newly linked records; check scopes separately from ownership |
| REST works but chat/tool fails | Delegated token required for chat, `agentChats:write`, and each tool's resource scopes and role permissions |
| Wrong user assigned as owner | Use `/core/users` membership `id`, not global `user_id` or `session.user.id` |

Do not log bearer tokens, client secrets, authorization codes, PKCE verifiers or refresh tokens while troubleshooting.

## Recommended integration pattern

For most external systems:

1. Create an OAuth client in Quake Platform for the target dataset.
2. Store `client_id` and the approved `redirect_uri`.
3. If the system is browser or native, implement PKCE.
4. Redirect the user to `/api/openapi/authorize`.
5. On callback, exchange the authorization code at `/oauth/session`.
6. Store the returned `refresh_token` securely.
7. Use the returned `access_token` for `/core/*` requests.
8. If building an external chat UI, call `/stream-api/v1/agent-chat` with the same bearer token and consume the SSE stream.
9. Refresh through `/oauth/session/refresh` before expiry; handle a `401` without blindly replaying writes.
10. Optionally call `/session` to bootstrap UI state with the current user, dataset, and granted scopes.

## Difference from app-only OAuth

Use delegated sessions when you need a real Quake user in the loop.

Use app-only OAuth when you only need dataset-scoped client access and do not need user identity or user-permission filtering.

In practice:

- delegated session gives you user identity, user role permissions, MFA summary, and refresh token rotation
- app-only token gives you client identity and dataset scope only

## Validation aid inside Quake

Quake Platform includes an internal page at `/delegated-oauth-test` that starts the delegated login flow and shows the exchange response and derived session payload. This is useful when validating a new client configuration before wiring it into another system.
