# External chat and files

Build a user-facing chat with Quake agents using delegated OAuth and POST-based Server-Sent Events (SSE). REST discovery and history use `https://api.quake.dev/core`; chat uses `https://api.quake.dev/stream-api/v1/agent-chat`. Authorize the user at `https://app.quake.dev/api/openapi/authorize`.

Start with [delegated sessions](https://api.quake.dev/documentation/guides/delegated-openapi-sessions) for PKCE, callback validation, exchange at `/oauth/session`, refresh at `/oauth/session/refresh`, and `/session` introspection. A browser/public client must use PKCE and must never embed a client secret. The session and OAuth client must belong to the same dataset. An app-only token cannot call the external chat stream.

## Discover the user's choices

Grant `agentChats:write` for this flow. The same scope covers the conversation and chat-file REST endpoints. Additional agent tools require their own scopes and the user's role permissions; permission to chat does not grant every tool access. For example, a search tool can require multiple resource scopes. Check the permission recipes in the delegated-session guide and surface tool failures to the user.

| Request (relative to `/core`) | Use |
| --- | --- |
| `GET /ai-agents` | Agents visible to the delegated user; use an agent's `id` as `agent_id`. App tokens with appropriate scope can discover all dataset agents. |
| `GET /llm-models/allowed` | Dataset-enabled models; use `name` as `llm_model`, not `displayName`. |
| `GET /ai-skills/available` | Skills available to this delegated user; send chosen IDs as `selected_skill_ids`. |
| `GET /ai-conversations?limit=100&offset=0` | Owned conversations for a delegated user, returned as an array. Optional `search` is plain text. App tokens can list dataset conversations. |
| `GET /ai-conversations/{conversationId}/history` | Persisted messages, tools, metadata, feedback and attachment URLs. Delegated access follows conversation collaboration rules; app tokens can read dataset conversations. |

Model and agent availability can change. Discover choices rather than hardcoding names or IDs. Do not assume that an agent's visible tool list grants permission to execute every tool.

## Start and continue a conversation

POST JSON with at least `message` and the delegated bearer token. Omit `conversation_id` for a new conversation. Save the `conversation_created` event's `content` as the conversation ID and send it in subsequent requests. A caller can supply `new_conversation_id` (a UUID) when starting a conversation to retain a known ID for later history lookup. This is not a message idempotency key and does not make reposting safe.

```json
{
  "message": "Summarize the attached interview notes.",
  "agent_id": "<id from /core/ai-agents>",
  "llm_model": "<name from /core/llm-models/allowed>",
  "filesIds": ["<id returned by /core/ai-conversations/files>"],
  "selected_skill_ids": ["<id from /core/ai-skills/available>"]
}
```

The `filesIds` spelling is case-sensitive. `mode` can be `thinking` or `fast`. Consult the current operation schema for optional record context and other public fields. Rename with `PATCH /core/ai-conversations/{conversationId}` and `{ "name": "Interview notes" }`; delete with `DELETE` at the same path. These mutations require `agentChats:write` and delegated ownership/collaboration authorization appropriate to the operation.

## Working TypeScript POST/SSE example

Use `fetch`, because browser `EventSource` cannot send this POST JSON body and bearer header. The repository's `examples/openapi/agent-chat-client.ts` exports `postAgentChat` and `consumeChatSse`. It handles UTF-8 split across network chunks, LF/CRLF/CR framing, multiline `data`, comments, HTTP failures, content-type validation, and reader cleanup. Import it into your client alongside a handler like this:

```typescript
import { postAgentChat } from "./agent-chat-client.ts";

const controller = new AbortController();
let conversationId: string | undefined;
let assistantText = "";
let runtimeFailed = false;
const pendingApprovals = new Map<string, unknown>();

await postAgentChat({
  accessToken, // obtained from the delegated session flow
  input: { message: "Hello", conversation_id: conversationId },
  signal: controller.signal,
  onEvent(event) {
    switch (event.type) {
      case "conversation_created":
        conversationId = event.content;
        break;
      case "chat_stream":
        assistantText += event.content; // a delta
        break;
      case "chat_end":
        assistantText = event.content; // authoritative content for this assistant message
        console.log(assistantText);
        assistantText = "";
        break;
      case "approval_request":
        if (event.message_id) pendingApprovals.set(event.message_id, event);
        // Render tool arguments and approval_data for an explicit user decision.
        break;
      case "approval_result":
        if (event.message_id) pendingApprovals.delete(event.message_id);
        break;
      case "tool_call_attempt":
      case "tool_call":
      case "tool_stream":
      case "tool_response":
      case "tool_call_error":
        console.log(event.type, event.tools, event.meta, event.content);
        break;
      case "runtime_error":
        runtimeFailed = true;
        console.error(event.content);
        break;
      case "status":
      case "signal":
      case "heartbeat":
        break; // progress / transport liveness, not assistant text
      default:
        console.debug("Unrecognized event", event);
    }
  },
});
console.log(runtimeFailed ? "Turn failed; inspect history" : "Stream closed");
// Wire a Stop button to controller.abort().
```

Run its offline transport checks with `node --test examples/openapi/agent-chat-client.test.ts` on Node 24. The small static SPA in `examples/openapi` demonstrates login and text streaming; it does not implement the file/approval UI or production token storage. The reusable transport does not refresh tokens or automatically retry chat submissions.

`chat_end` ends an assistant message, not necessarily the whole turn: tool activity and more assistant messages can follow. Track separate message blocks, correlate tool calls by `tools[].id` and tool responses by `meta.tool_response.tool_id`, and retain `message_id` when supplied. Metadata can contain reasoning or record results; render only fields your UI supports and tolerate unknown fields/events. Treat tool output as untrusted content when rendering HTML or links.

### Approvals

`approval_request` carries `message_id`, `tools`, and `meta.approval_data`. Show the action and its arguments, then obtain an explicit user decision. Continue the same conversation with another streamed POST:

```json
{
  "conversation_id": "<conversation UUID>",
  "message": "",
  "approval_request": {
    "message_id": "<approval_request.message_id>",
    "approved": true
  }
}
```

Use `approved: false` to reject. Handle `approval_result` and subsequent tool/chat events on the new response. Do not silently approve tools or interpret stream closure as approval. Hydrate pending approvals from history when reconnecting your UI.

### Errors, disconnects and stopping

- Before SSE starts, authentication, JSON validation and initial conversation authorization can return a non-2xx HTTP response. Check the status before reading events; a refreshed token may fix a rejected request, but decide whether submission was accepted before reposting.
- After SSE starts, HTTP status can remain 200 while `runtime_error` reports a failed turn. Tool-specific failures use `tool_call_error`; inspect its content rather than treating it as successful execution. A tool failure can be followed by an assistant explanation.
- Abort the fetch with `AbortController` for a user stop. The server attempts to stop the underlying stream on iterator cancellation/disconnect. This is not rollback: tool side effects already performed can remain, and transport/proxy disconnect detection is not an immediate cancellation guarantee.
- A dropped connection or truncated event leaves the result uncertain. Reload conversation history and verify any affected records before deciding to send again. There is no public replay cursor, `Last-Event-ID` resume guarantee, or exactly-once message submission contract. History is persisted state, not a replay of every SSE frame.

## Upload and attach files

Use the delegated bearer token for REST calls below. Send raw bytes only to the returned presigned URL; do not send the Quake bearer token to S3.

1. **Request upload URLs.** POST `/core/ai-conversations/files/upload-urls` with file metadata. Supply `conversation_id` when uploading to an existing conversation so its access and accumulated attachment size are checked.

   ```json
   {
     "conversation_id": "<existing conversation UUID; omit for a new conversation>",
     "files": [{
       "full_name": "interview.txt",
       "file_name": "interview.txt",
       "size": 1234,
       "extension": "txt",
       "mime_type": "text/plain"
     }]
   }
   ```

2. **Upload the bytes.** For each returned file, PUT the actual byte body to `upload_url`, with the same `Content-Type` as `mime_type`. Check the S3 HTTP status before continuing. These upload URLs currently expire after ten minutes; request a fresh URL if upload has not completed before expiry. Keep the returned `file_key` for the next step.

   ```typescript
   const uploaded = await fetch(uploadUrl, {
     method: "PUT",
     headers: { "Content-Type": "text/plain" },
     body: fileBytes,
   });
   if (!uploaded.ok) throw new Error(`Upload HTTP ${uploaded.status}`);
   ```

3. **Create private file records.** POST `/core/ai-conversations/files` after upload, using the returned key and matching metadata. Text/document attachments needing extracted text should set `require_parse: true`. Parsing is queued by default; `parse_async: false` requests processing in the create call and can make that request slower.

   ```json
   {
     "files": [{
       "file_key": "<file_key from upload URLs response>",
       "file_name": "interview.txt",
       "file_size": 1234,
       "mime_type": "text/plain",
       "file_extension": "txt",
       "require_parse": true,
       "parse_async": true
     }]
   }
   ```

4. **Check processing.** Save `files[].id`; POST `/core/ai-conversations/files/status` with `{ "file_ids": ["<id>"] }` (1–20 IDs). Poll with bounded backoff while `parse_status` is `queued` or `processing`. `ready` means processing completed; `not_required` means parsing was not requested. If `failed`, show `parse_error_code` and `parse_error_message`, correct/remove the file or POST `/core/ai-conversations/files/{file_id}/retry`, then poll again. Status lookup is dataset-scoped and can return fewer files than requested; a missing result is not evidence of readiness.

5. **Send the message.** Supply ready/usable record IDs as `filesIds` in the chat POST. File keys, upload URLs and filenames are not attachment IDs. Chat validates dataset membership, private-file access, processing state and content mode; creation/status success alone does not guarantee attachment usability.

File record creation currently reuses a dataset/file-key record on repeated submission of the same key. This narrow implementation behavior is not a general mutation idempotency guarantee. A multi-file create can persist some records before returning an error; retain keys and metadata and reconcile that attempt rather than uploading duplicate objects blindly.

### Limits and current production constraints

- Up to 20 files per upload/create request and per chat message; status lookup also accepts up to 20 IDs.
- Without Agent Compute, individual attachments can be at most 30,000,000 bytes and the accumulated conversation total must be **less than** 30,000,000 bytes. Sizes must be positive safe integers. Include `conversation_id` during upload URL creation to account for existing attachments.
- With Agent Compute enabled for the dataset, CSV/XLSX files can be up to 1 GB each and conversation attachments up to 2 GB total; other files retain the 30 MB individual limit. Availability depends on dataset configuration and actual processing support.
- Parsing currently supports `txt`, `rtf`, `pdf`, `csv`, `xlsx`, `doc`, `docx` and `pptx`. `require_parse` defaults to false; omitting it does not request text extraction. A failed or still-processing attachment cannot be used in chat.
- `content_mode` reports `inline`, `retrieval`, `table`, `analysis` or `compute`. Large-document retrieval is not generally available: a parsed `ready` file in retrieval mode can still be rejected as too large for inline chat. Compute attachments require Agent Compute to remain enabled. Surface the returned error and allow removal/replacement rather than promising every parsed document works.
- Creation and history return signed preview/download URLs with finite lifetimes (currently two hours by default). Treat URLs as private bearer capabilities; do not publish them or store them as permanent document identifiers.

### Generated artifacts

Successful `execute_code` / `generate_image` tool responses can include downloadable artifacts. Preserve the original artifact URL from the persisted tool response. To renew its download URL, POST `/core/ai-conversations/{conversationId}/generated-files/download` with `{ "url": "<original artifact URL>" }` and `agentChats:write`. Delegated users must retain conversation collaboration access; app tokens have dataset access.

Artifacts must still exist and be less than seven days old. Renewed URLs last up to one hour, capped by the remaining retention period. Renewal does not extend artifact retention. A 404 can mean an unavailable conversation, deleted artifact or expired artifact; do not promise indefinite downloads.

## Implementation references

The current contract is defined by `src/api/streams-api/agent-chat.shared.ts`, `src/api/streams-api/server/streams-api.server.ts`, and the OpenAPI routes `ai-agents.route.ts`, `llm-models.route.ts`, `ai-skills.route.ts`, `ai-conversations.route.ts`, and `ai-conversation-files.route.ts`. File limits/processing are implemented in `chat-attachment-files.service.ts`, `files.service.ts` and `LLMConversations.service/LLMChat.service.ts`. These details describe current behavior; no additional replay, compatibility or retention policy is implied.
