# ai-magaldi-dev API

Base URL: `http://localhost:9724` (default; overridden by `PORT`).

## `GET /`

Non-browser requests (no `text/html` in `Accept`, e.g. `curl`) get this document, same
as `GET /docs` — this app's middleware rewrites the request internally, so it's still a
plain `200`, not a redirect. A browser navigating to `/` instead sees a dashboard where a
person can sign in with Google and view their own agents/prompts/convos read-only. That
dashboard reads directly from this same app's data layer once signed in — it does **not**
go through `/api/*` itself — so the documented `/api/*` contract below is unaffected and
stays server-to-server: only a browser's `GET /` gets routed to a UI instead of these
docs.

## Auth

This is a server-to-server API — end users never call it directly. Every route under
`/api/*` requires **both** of the following, with no exceptions:

1. `Authorization: Bearer <token>` — a service credential.

   **This API does not issue tokens or verify Google credentials itself.** The token
   must come from a separate system: the auth broker, `auth-magaldi-dev`, at
   `https://auth.magaldi.dev` by default (this app's `AUTH_BASE_URL` config —
   overridden per-deployment, so confirm with whoever runs it if unsure). This API
   only checks the token's signature against that broker's public keys — it has no
   other way to accept a caller.

   The token is **not**:
   - the service account's raw JSON key file / its contents (`private_key`, etc.)
   - a Google-issued ID token or access token, used as-is
   - anything you generate or sign yourself

   The token **is** the `access_token` field returned by the broker's own
   `POST /oauth/token` endpoint, after a token-exchange call you make to the broker
   first. Concretely, to go from a service account key file to a valid header here:

   1. Using that service account's credentials, mint a **Google-signed ID token**
      with audience set to the broker's base URL (e.g. `google-auth-library`'s
      `getIdTokenClient(audience)` in Node, or `gcloud auth print-identity-token
      --audiences=https://auth.magaldi.dev` from the CLI). The service account's
      email must already be allowlisted on the broker's side, or this whole chain
      fails regardless of what you send.
   2. Send that ID token to the broker, not to this API:
      ```
      POST https://auth.magaldi.dev/oauth/token
      Content-Type: application/x-www-form-urlencoded

      grant_type=urn:ietf:params:oauth:grant-type:token-exchange
      subject_token=<google_id_token_from_step_1>
      subject_token_type=urn:ietf:params:oauth:token-type:id_token
      ```
   3. The broker's JSON response has an `access_token` — a short-lived RS256 JWT
      minted by the broker itself. **That** is the value that goes in this API's
      `Authorization: Bearer <access_token>` header. It expires (`expires_in`
      seconds) — re-run this exchange to get a new one; do not cache it long-term.

   Any other value in `Authorization` — the key file, a bare Google token, an empty
   or made-up string — fails verification against this API's JWKS check and returns
   `401 invalid_token`, even though the request looks well-formed. Missing tokens get
   `401` with a `WWW-Authenticate: Bearer` header.
2. `X-End-User-Email: <email>` — the end user the calling service is acting on behalf
   of. Required on every `/api/*` route, including ones over global data like
   `/api/providers` — a request must always be attributed to a specific end user, even
   if that route doesn't (yet) use it to scope data. Missing or non-email-shaped values
   get `400 invalid_request`. There is no way to opt out of this header. A service ticket
   may name any end user — that's intentional — but the email is verified against the
   auth broker's `/users/lookup` on every request; an email with no matching user also
   gets `400 invalid_request`, and a broker that can't be reached gets
   `503 temporarily_unavailable`.

For `agents`/`prompts`/`convos`/`messages`/`llm_requests`, that email also drives data
isolation: every one of those tables carries its own `endUserId` column and is scoped
to it directly via Postgres row-level security — there is no way to read or modify
another user's rows in any of them, even by guessing an id.

## `GET /health`

No auth required.

```json
{ "status": "ok" }
```

## `GET /api/providers`

Requires `Authorization: Bearer <token>` and `X-End-User-Email: <email>` (see Auth —
required on every `/api/*` route, though this one doesn't use it for scoping).

Returns all active providers (global reference data — the same rows for every
authenticated caller; not scoped per-caller).

**Success — 200**

```json
[
  {
    "id": "uuid",
    "name": "string",
    "slug": "openai",
    "logoUrl": "string | null",
    "active": true
  }
]
```

Rows are ordered by `name`. `logoUrl`, when set, points at a static file served from
both `/<file>` and `/providers/<file>`. `slug` is the stable key this API uses
internally to pick an LLM adapter for a `convo`'s provider — pass the provider's `id`
(not its `slug`) when creating a convo.

## `GET /api/agents`

Requires `Authorization: Bearer <token>` and `X-End-User-Email: <email>`.

Returns the agents owned by that end user, ordered by `name`.

**Success — 200**

```json
[
  {
    "id": "uuid",
    "name": "string",
    "endUserId": "user@example.com"
  }
]
```

## `POST /api/agents`

Requires `Authorization: Bearer <token>` and `X-End-User-Email: <email>`.

Creates an agent owned by the end user in `X-End-User-Email` — `endUserId` is set
from that header, never from the request body.

**Request body**

```json
{
  "name": "string"
}
```

**Success — 201**

```json
{
  "id": "uuid",
  "name": "string",
  "endUserId": "user@example.com"
}
```

## `PATCH /api/agents/:id`

Requires `Authorization: Bearer <token>` and `X-End-User-Email: <email>`.

Renames an agent owned by that end user.

**Request body**

```json
{
  "name": "string"
}
```

**Success — 200**

```json
{
  "id": "uuid",
  "name": "string",
  "endUserId": "user@example.com"
}
```

**Errors** — `400 invalid_request` if `name` is missing; `404 invalid_request` if
`id` does not reference an agent owned by this end user.

## `DELETE /api/agents/:id`

Requires `Authorization: Bearer <token>` and `X-End-User-Email: <email>`.

Deletes an agent owned by that end user. Its prompts are **not** cascade-deleted —
delete them first.

**Success — 200**

```json
{
  "id": "uuid",
  "name": "string",
  "endUserId": "user@example.com"
}
```

**Errors** — `404 invalid_request` if `id` does not reference an agent owned by this
end user; `409 invalid_request` if the agent still has prompts.

## `GET /api/agents/:agentId/prompts`

Requires `Authorization: Bearer <token>` and `X-End-User-Email: <email>`.

Returns every prompt belonging to the given agent, newest first. If `agentId` belongs
to a different end user, this returns an empty list (RLS hides it, same as if it
didn't exist).

**Success — 200**

```json
[
  {
    "id": "uuid",
    "name": "string",
    "text": "string",
    "responseJsonSchema": {} ,
    "agentId": "uuid",
    "endUserId": "user@example.com",
    "isActive": false,
    "createdAt": "2026-07-24T00:00:00.000Z"
  }
]
```

## `POST /api/agents/:agentId/prompts`

Requires `Authorization: Bearer <token>` and `X-End-User-Email: <email>`.

Creates a new prompt for the agent. New prompts start with `isActive: false` —
use `PATCH /api/prompts/:promptId` with `{ "isActive": true }` to make one official.

**Request body**

```json
{
  "name": "string",
  "text": "string",
  "responseJsonSchema": { "type": "object" }
}
```

`responseJsonSchema` is optional.

**Success — 201**

```json
{
  "id": "uuid",
  "name": "string",
  "text": "string",
  "responseJsonSchema": { "type": "object" },
  "agentId": "uuid",
  "endUserId": "user@example.com",
  "isActive": false,
  "createdAt": "2026-07-24T00:00:00.000Z"
}
```

**Errors** — `400 invalid_request` if `name`/`text` are missing, or if `agentId` does
not reference an agent owned by this end user (including agents owned by someone else,
which are indistinguishable from non-existent for this purpose).

## `PATCH /api/prompts/:promptId`

Requires `Authorization: Bearer <token>` and `X-End-User-Email: <email>`.

Updates a prompt owned by this end user. All fields are optional, but at least one
must be present. Setting `isActive: true` fails if the agent already has a
different active prompt — deactivate it first with its own
`PATCH .../isActive: false`; an agent never has more than one active prompt.

**Request body**

```json
{
  "name": "string",
  "text": "string",
  "responseJsonSchema": { "type": "object" },
  "isActive": true
}
```

**Success — 200**

```json
{
  "id": "uuid",
  "name": "string",
  "text": "string",
  "responseJsonSchema": { "type": "object" },
  "agentId": "uuid",
  "endUserId": "user@example.com",
  "isActive": true,
  "createdAt": "2026-07-24T00:00:00.000Z"
}
```

**Errors** — `400 invalid_request` if no fields are given, `name`/`text` are
present but empty, or `isActive` is not a boolean; `404 invalid_request` if
`promptId` does not reference a prompt owned by this end user; `409 invalid_request`
if `isActive: true` and the agent already has a different active prompt.

## `DELETE /api/prompts/:promptId`

Requires `Authorization: Bearer <token>` and `X-End-User-Email: <email>`.

Deletes a prompt owned by this end user.

**Success — 200**

```json
{
  "id": "uuid",
  "name": "string",
  "text": "string",
  "responseJsonSchema": { "type": "object" },
  "agentId": "uuid",
  "endUserId": "user@example.com",
  "isActive": false,
  "createdAt": "2026-07-24T00:00:00.000Z"
}
```

**Errors** — `404 invalid_request` if `promptId` does not reference a prompt owned
by this end user.

## `GET /api/agents/:agentId/convos`

Requires `Authorization: Bearer <token>` and `X-End-User-Email: <email>`.

Returns the convos for that agent, newest first.

**Success — 200**

```json
[
  {
    "id": "uuid",
    "name": "New convo",
    "agentId": "uuid",
    "providerId": "uuid",
    "endUserId": "user@example.com",
    "externalRefId": "string | null",
    "createdAt": "2026-07-24T00:00:00.000Z"
  }
]
```

## `POST /api/agents/:agentId/convos`

Requires `Authorization: Bearer <token>` and `X-End-User-Email: <email>`.

Creates a convo for the agent. `externalRefId` is a free-form, unvalidated reference
to anything a consumer wants to associate with the convo (a job id, a ticket id,
anything) — this API stores it but never checks it against another service.

**Request body**

```json
{
  "name": "string",
  "providerId": "uuid",
  "externalRefId": "string"
}
```

`name` defaults to `"New convo"` if omitted. `externalRefId` is optional.

**Success — 201**

```json
{
  "id": "uuid",
  "name": "New convo",
  "agentId": "uuid",
  "providerId": "uuid",
  "endUserId": "user@example.com",
  "externalRefId": "string | null",
  "createdAt": "2026-07-24T00:00:00.000Z"
}
```

**Errors** — `400 invalid_request` if `providerId` is missing, `agentId` does not
reference an agent owned by this end user, or `providerId` does not reference an
existing provider.

## `GET /api/convos/:convoId`

Requires `Authorization: Bearer <token>` and `X-End-User-Email: <email>`.

Returns the convo along with its full message history, oldest first.

**Success — 200**

```json
{
  "id": "uuid",
  "name": "New convo",
  "agentId": "uuid",
  "providerId": "uuid",
  "endUserId": "user@example.com",
  "externalRefId": "string | null",
  "createdAt": "2026-07-24T00:00:00.000Z",
  "messages": [
    {
      "id": "uuid",
      "convoId": "uuid",
      "endUserId": "user@example.com",
      "type": "user",
      "text": "string",
      "fullText": "string",
      "sentAt": "2026-07-24T00:00:00.000Z"
    },
    {
      "id": "uuid",
      "convoId": "uuid",
      "endUserId": "user@example.com",
      "type": "assistant",
      "text": "string",
      "fullText": null,
      "sentAt": "2026-07-24T00:00:00.000Z"
    }
  ]
}
```

`fullText` is only ever set on a `user`-type message: the *exact* request body string
the provider's own adapter built and sent for the call that message triggered —
verbatim, not reformatted, not reconstructed after the fact. It's captured and
persisted in the same function that sends it (`sendLlmRequest`), so there is no
possibility of it differing from what was actually transmitted by even one character —
that guarantee is also why it's a plain string column rather than JSON: a JSON column
can silently reorder keys or reformat whitespace on storage, which would make the saved
value byte-different from what was sent. It's independent of `prompts`, which can be
edited or deactivated after the fact — this is the exact prompt text as it was, not a
pointer to a row that might now say something else. `text` is just this turn's own
content (what gets replayed as history on the *next* call) — `fullText` would make that
replay balloon with every previous call's full request body.

**Errors** — `404 invalid_request` if `convoId` does not reference a convo owned by
this end user.

## `POST /api/convos/:convoId/llm_requests`

Requires `Authorization: Bearer <token>` and `X-End-User-Email: <email>`.

Sends `content` to the convo's provider as a new turn, along with the convo's entire
message history so far, and records the exchange. There is no separate "create a
message" endpoint, and no way to write a `llm_requests` row with caller-supplied
data — this is the only path, since a message with no LLM call behind it wouldn't
mean anything here.

Three things must already be true, checked in this order, each failing descriptively
before anything is written:

1. `convoId` must reference an existing convo owned by this end user.
2. That convo's agent must exist (checked explicitly).
3. That agent must have an active prompt — its `text` is sent as the system
   instructions for this call, and gets captured verbatim in the created user
   message's `fullText` (see `GET /api/convos/:convoId`). There is no way to override
   the prompt per-request; activate the one you want first with
   `PATCH /api/prompts/:promptId`.

**Request body**

```json
{
  "content": "string"
}
```

**Success — 201**

The response always represents a created record — a failed provider call is still a
`201` with `status: "failed"`, not an HTTP error, since the record (and the user's
message) were successfully persisted either way.

```json
{
  "id": "uuid",
  "convoId": "uuid",
  "agentId": "uuid",
  "endUserId": "user@example.com",
  "userMessageId": "uuid",
  "assistantMessageId": "uuid | null",
  "status": "succeeded",
  "errorMessage": "string | null",
  "inputTokens": "number | null",
  "outputTokens": "number | null",
  "cacheTokens": "number | null",
  "rawUsage": "object | null",
  "createdAt": "2026-07-24T00:00:00.000Z",
  "userMessage": {
    "id": "uuid",
    "convoId": "uuid",
    "endUserId": "user@example.com",
    "type": "user",
    "text": "string",
    "fullText": "string",
    "sentAt": "..."
  },
  "assistantMessage": {
    "id": "uuid",
    "convoId": "uuid",
    "endUserId": "user@example.com",
    "type": "assistant",
    "text": "string",
    "fullText": null,
    "sentAt": "..."
  }
}
```

On `status: "failed"`, `assistantMessage` is `null`, `errorMessage` explains why (e.g.
the provider's API returned an error), and the usage fields are `null` — there's no
usage data for a call that never got a response. On success, `inputTokens`/
`outputTokens` come straight from the provider's usage response; `cacheTokens` is
whatever that provider reports as cached/reused input (0 if it doesn't report one);
`rawUsage` is that provider's usage object verbatim, for anything beyond those three
normalized counts.

**Errors** — `400 invalid_request` if `content` is missing, or the convo's agent has
no active prompt; `404 invalid_request` if `convoId` does not reference a convo owned
by this end user, or (in the unexpected case its agent no longer exists) that agent
does not exist; `500 provider_not_configured` if the convo's provider has no API key
configured on this server.

## Errors (shape used across `/api/*`)

```json
{ "error": "invalid_token", "error_description": "Token verification failed" }
```

Common `error` values: `invalid_request`, `invalid_token`.
