# Documentation

Dynjandi stores your images and serves resized, cropped and re-encoded variants of them from `cdn.dynjandi.dev`. This page covers the URLs, uploading, API keys, usage and the MCP server.

- [URLs](#urls)
- [Upload](#upload)
- [API keys](#api-keys)
- [Usage](#usage)
- [MCP server](#mcp-server)

Reference documents:

- [The management API](https://app.dynjandi.dev/api/openapi.json), as an OpenAPI 3.1 document. It lists the routes an API key can reach.
- [The public-key API](https://raw.githubusercontent.com/dynjandi-dev/dynjandi/main/packages/spec/openapi.yaml), as an OpenAPI document in the [public repository](https://github.com/dynjandi-dev/dynjandi).
- [`@dynjandi/transform-grammar`](https://www.npmjs.com/package/@dynjandi/transform-grammar) on npm: the URL grammar, which parses and builds variant URLs.
- [`@dynjandi/sdk`](https://www.npmjs.com/package/@dynjandi/sdk) on npm: a TypeScript SDK for Dynjandi.

## URLs

A variant URL is the file id followed by operations. Each operation starts with `/-/`, and operations apply in order. The file id alone, `https://cdn.dynjandi.dev/{id}`, is the original.

```
https://cdn.dynjandi.dev/{id}/-/resize/800x/-/format/webp/-/quality/75/
https://cdn.dynjandi.dev/{id}/-/crop/400x400/center/
https://cdn.dynjandi.dev/{id}/-/scale_crop/1280x400/focal/0.42x0.18/
https://cdn.dynjandi.dev/{id}/-/scale_crop/1280x400/face,attention/
```

### Operations

| Operation | Form | Notes |
| --- | --- | --- |
| `resize` | `resize/{w}x{h}`, `resize/{w}x` or `resize/x{h}` | Width, height or both. At least one is required. |
| `crop` | `crop/{w}x{h}/{position}` | The position is required. |
| `scale_crop` | `scale_crop/{w}x{h}/{position}` | The position is optional and defaults to `center`. |
| `format` | `format/{format}` | `jpg` is accepted as `jpeg`. |
| `quality` | `quality/{n}` | An integer, or a keyword: `lightest` is 55, `lighter` 70, `normal` and `smart` 80, `better` 88 and `best` 95. |

### Limits and values

| What | Allowed |
| --- | --- |
| Width and height | 1 to 10,000 pixels |
| Quality | 1 to 100 |
| Focal coordinates | 0 to 1 |
| Formats | `jpeg`, `png`, `webp`, `avif` |
| Positions | `center`, `north`, `south`, `east`, `west`, `northeast`, `northwest`, `southeast`, `southwest`, `attention`, `entropy` |

### Positions

- A position names where a crop is anchored. `focal/{x}x{y}` anchors it on a point, with each coordinate a fraction between 0 and 1.
- `face` runs face detection. When no face is found the crop falls back to the next position, so `face,attention` tries a face and then `attention`. A bare `face` means `face,center`.
- A fallback chain is `face` followed by one position from the table above. `focal` cannot be part of a chain.

### Uploadcare URLs

Uploadcare spellings are accepted and normalised to the canonical form, so both spellings share one cache entry.

| Uploadcare | Canonical |
| --- | --- |
| `scale_crop/650x300/smart` | `crop/650x300/face,center` |
| `quality/smart` | `quality/80` |
| a trailing `/image.jpeg` | ignored |
| empty path segments (`//`) | ignored |

### Caching

A variant is rendered on its first request and served from the cache after that. Variants are served with `Cache-Control: public, max-age=31536000, immutable`. A URL the grammar rejects answers `400`.

## Upload

There are two ways to upload. Both take a `multipart/form-data` body with a `file` part and, optionally, `focalX` and `focalY` parts. The focal point is set with both fields or neither.

### With a public key

```
POST https://cdn.dynjandi.dev/upload
X-Public-Key: {public key}
Content-Type: multipart/form-data
```

The public key is the only credential this route checks, so send it from your server and not from a page. A successful upload answers `201` with `file`, `id`, `url`, `focalX` and `focalY`. The response is `Cache-Control: no-store`.

This route reports an error as `{ "error": "message" }`, with a string message.

| Status | When |
| --- | --- |
| `400` | The body is not multipart or cannot be read, there is no file or it is empty, or the focal fields are incomplete or out of range. |
| `401` | The `X-Public-Key` header is missing or the key is not valid. |
| `402` | The workspace does not allow uploads, or the upload would pass its storage cap. |
| `413` | The file is larger than the project's maximum file size, or larger than its remaining storage quota. |

### With an API key

```
POST https://app.dynjandi.dev/api/projects/{id}/files
Authorization: Bearer dyn_...
Content-Type: multipart/form-data
```

This route reports an error as a JSON body such as `{ "error": { "code": "NOT_FOUND", "message": "…" } }`, where `code` and `message` are strings and an optional `details` object may sit beside them. The statuses are `400`, `401`, `403`, `404` and `429`, and, for this route, `402`, `413` and `502`. A `429` carries `Retry-After`.

## API keys

An API key lets a server call the management API without a browser session.

- A member creates a key for their own workspace in the dashboard, on the API keys page at `app.dynjandi.dev/api-keys`. A key cannot reach the routes that create, list or revoke keys, so a leaked key cannot mint its own replacement.
- A key expires in 30, 90 or 365 days, and 90 is the default. A key never lasts without an expiry.
- A workspace holds at most 10 unexpired keys. The name is 1 to 64 characters.
- A key starts with `dyn_`. The key itself is shown once, when it is created, and cannot be read back afterwards. A member can revoke a key from the dashboard.
- A key acts as a member of the one workspace it was created for, and never as a superadmin.

### Sending a key

```
GET https://app.dynjandi.dev/api/workspace
Authorization: Bearer dyn_...
```

- The key goes in the `Authorization: Bearer` header. When that header is present the session cookie is never read, so an invalid key does not fall back to a cookie.
- A key is meant for servers: the management API is called by servers with a key, and it has no CORS.
- Treat a key like a password. Keep it out of URLs and out of version control.

### When a request is refused

| Status | When |
| --- | --- |
| `401` | The key is expired, revoked or malformed, or its owner is disabled, is a superadmin, is no longer a member of the workspace, or is gone. Every one of these answers the same `401`. |
| `404` | The route is not on the list below. A route a key cannot reach answers `404`, not `403`. Another workspace's project or file also answers `404`. |
| `429` | A key is limited to 120 requests a minute, and a client address to 10 failed attempts a minute. A `429` carries `Retry-After: 60`. |

### Routes a key can reach

Only these routes accept a key. Every route in the table also accepts `HEAD`, except `/api/files/{id}/focal-suggestion`, which is `GET` only.

| Route | Methods |
| --- | --- |
| `/api/workspace` | `GET` |
| `/api/workspace/usage` | `GET` |
| `/api/projects` | `GET`, `POST` |
| `/api/projects/{id}` | `GET`, `PATCH` |
| `/api/projects/{id}/files` | `GET`, `POST` |
| `/api/files/{id}` | `GET`, `PATCH`, `DELETE` |
| `/api/files/{id}/focal-suggestion` | `GET` |

## Usage

`GET /api/workspace/usage` reports on the one workspace the key was created for. `GET /api/workspace` returns the same body. A key names no workspace in the request, because it has exactly one. The body holds:

- `id`, `name`, `status`, `plan`, `billingStatus` and `cancelAtPeriodEnd`;
- `periodStart` and `periodEnd`;
- `caps`, with `storageBytes`, `variants` and `projects`, where `null` means uncapped; and
- `usage`, with `storageBytes`, `newVariants` and `projects`.

It does not report delivery bandwidth. Every response is `Cache-Control: no-store`, and errors use the `{ "error": { "code": "…", "message": "…" } }` body of every management API route.

```
GET https://app.dynjandi.dev/api/workspace/usage
Authorization: Bearer dyn_...
```

## MCP server

An agent can manage a workspace, build and check variant URLs and read these docs through a remote MCP server. It uses the same API key as the management API.

```
https://mcp.dynjandi.dev/mcp
```

### Connecting

- The server speaks MCP over Streamable HTTP, and every request is a `POST` to `/mcp`.
- Every request carries `Authorization: Bearer` followed by an API key, set once in the client's configuration. There is no sign-in flow and no OAuth.
- The server is for MCP clients that run on a server or on your own machine. It answers any request that carries an `Origin` header with `403` and has no CORS, so a web page cannot call it.
- A request with no bearer header, or one that is not 1 to 256 characters without spaces, answers `401` with `WWW-Authenticate: Bearer realm="dynjandi"`. A client address is limited to 120 requests a minute, and a `429` carries `Retry-After: 60`.

The field names differ between clients. The URL and the header are what they all need:

```
{
  "mcpServers": {
    "dynjandi": {
      "type": "http",
      "url": "https://mcp.dynjandi.dev/mcp",
      "headers": {
        "Authorization": "Bearer dyn_..."
      }
    }
  }
}
```

Keep the configuration file out of version control, as for any other key.

### Tools

There are twelve tools. The first nine act on the one workspace the key was created for, and the dashboard checks the key on each of those calls. The last three need no key that is real, only a well-formed bearer, and they never call the dashboard.

| Tool | What it does |
| --- | --- |
| `get_workspace` | Returns the workspace: its plan, status, caps and usage. |
| `list_projects` | Lists the workspace's projects. |
| `get_project` | Returns one project with its file count and bytes used. |
| `create_project` | Creates a project. The plan may refuse it, for a cap or an inactive plan. |
| `rename_project` | Renames a project. The old name is overwritten. |
| `list_files` | Lists one page of a project's files, 24 to a page. |
| `get_file` | Returns one file's metadata, including its focal point. |
| `set_focal_point` | Sets a file's focal point, replacing the old one, or clears it with `null` for both coordinates. |
| `suggest_focal_point` | Asks the image service where the file's subject is. Nothing is saved, and each call costs container time. |
| `build_variant_url` | Builds a variant URL from a file id and a list of operations, and checks the operations against the limits above. |
| `parse_variant_url` | Parses a variant URL, or just its path, into its operations and its canonical URL. |
| `read_docs` | Returns one section of these docs by heading, or the list of headings. |

A tool failure comes back as an error result with an error code and a message, not as an HTTP status. A key the dashboard refuses is one such result.

### What is not a tool

Uploading a file and deleting a file are not tools. Both stay on the REST API: see [Upload](#upload) and [API keys](#api-keys). A project's public key is never returned by a tool.
