# KT Accounts for agents

KT Accounts (https://accounts.aidenkt.com) is the account service for aidenkt sites. This document is for an AI agent
that registers and manages apps with an agent key. It covers how to get the key, every
endpoint you can call, the errors you can get, and how an app then signs people in.

Human documentation: https://accounts.aidenkt.com/developers/docs

## 1. Get a key

You cannot make your own key. A person makes it for you:

1. Aiden (or someone Aiden has given the `agents.manage` permission) creates an agent in
   Account Settings at https://accounts.aidenkt.com/settings#agents and ticks the `apps.manage` permission. The
   person must hold `apps.manage` themselves; Aiden holds every permission.
2. They create a key for that agent and give it to you as a secret. It looks like
   `ktk_XXXXXXXX_<43 characters>` and expires after 90 days by default.

An agent can also ask for access with a code a person approves (RFC 8628): see
https://accounts.aidenkt.com/agents/approve and `POST /api/agent/device/code` in API.md. The result is the same: a key.

Keep the key out of logs, URLs and prompts. Never send it anywhere except `https://accounts.aidenkt.com/api/agent/token`.

## 2. Exchange the key for an access token

### `POST /api/agent/token`

```sh
curl -s -X POST https://accounts.aidenkt.com/api/agent/token -H "Authorization: Bearer $KT_AGENT_KEY"
```

```json
{ "access_token": "kta_…", "token_type": "Bearer", "expires_in": 3600, "scope": "apps.manage" }
```

The access token lasts 1 hour. Exchange the key again when it expires (401 `invalid_token`).
Limits: 30 a minute per IP address, 20 a minute per key. Use the access token, never the key,
on every other call:

```sh
export KT=https://accounts.aidenkt.com
export TOKEN=kta_…   # from /api/agent/token
```

### `GET /api/agent/whoami`

```sh
curl -s $KT/api/agent/whoami -H "Authorization: Bearer $TOKEN"
```

```json
{ "kind": "agent", "id": "…", "name": "Builder", "ownerUserId": "…", "permissions": ["apps.manage"],
  "owner": { "isOwner": true, "roles": ["aiden", "sysadmin"] }, "via": { "method": "key" } }
```

A server that receives requests from an agent can check the agent the same way: call this
endpoint with the access token the agent presented. `401 invalid_token` means it is not a
live token; `permissions` is what the agent may do right now (its own list, narrowed to what
its person currently holds). `owner` says who the agent works for, as far as standing goes:
`isOwner` is true when its person is Aiden (the owner of KT Accounts), and `roles` are that
person's badges (`aiden`, `sysadmin`, `partner`, `friend`, `developer`, `agent`, `test`). Nothing else about the person is shared.

If `permissions` does not include `apps.manage`, every call below answers
`403 {"error":"forbidden","permission":"apps.manage"}`. Ask the person who gave you the key.

## 3. Apps

An app is something people sign in to with KT Accounts. Registered apps go live at once;
there is no review step. You manage the apps that belong to your person (the ones they or
their agents registered). You cannot see or change anyone else's apps, the three built-in
apps (aidenkt.com, Brand Book, The Garden), or which people use an app.

### The app object

```json
{
  "id": "Xb3…", "slug": "notes", "name": "Notes", "homepageUrl": "https://notes.example.com/",
  "iconUrl": null, "builtin": null, "color": "kt-green", "accent": "#15DD7D",
  "audience": "everyone", "position": 40, "status": "active",
  "clientId": "ktc_…", "clientType": "confidential", "hasSecret": true, "secretRotatedAt": 1794779798000,
  "origins": ["https://notes.aidenkt.com"], "redirectUris": ["https://notes.example.com/auth/callback"],
  "ownerUserId": "…", "createdBy": { "kind": "agent", "id": "…", "name": null },
  "createdAt": 1794779798000, "updatedAt": 1794779798000, "suspendedAt": null
}
```

The client secret is never part of it. It is returned once, as `clientSecret`, when the app is
created, when the secret is rotated, and when a public client becomes confidential.

### Fields you can send

| Field | Rules |
| --- | --- |
| `name` | required on create; 1 to 40 characters after cleanup |
| `homepageUrl` | required on create; https (`http://localhost` only in development); where the app's tile links. It must be on one of the app's `origins` or on the host of one of its `redirectUris`, and never on https://accounts.aidenkt.com or another origin KT Accounts lists (aidenkt.com, www.aidenkt.com and the like) |
| `slug` | optional; 2 to 32 of `a-z`, `0-9`, `-`; made from the name when left out; unique |
| `color` | optional, default `kt-green`; one key from the aidenkt palette (list below); nothing else is accepted, not even a hex value |
| `audience` | optional, default `everyone`; who sees the app's tile: `everyone`, `owner`, `permission:<name>`, or `roles:<badge>,<badge>` for people with any of those roles. Every badge implies the permission `role.<badge>`, so `roles:friend,partner` means "holds role.friend or role.partner", and `permission:role.friend` works too. Badge keys: `aiden`, `sysadmin`, `partner`, `friend`, `developer`, `agent`, `test`; any other key is `400 invalid_audience`. The owner sees every app |
| `origins` | optional list (at most 10) of exact origins the app runs on: `https://host` or `https://host:port`, no path, no trailing slash, no wildcard. Not the accounts origin, not an origin KT Accounts already lists, and not one another app has |
| `redirectUris` | optional list (at most 10) of OpenID Connect redirect URIs: https, no fragment, no wildcard, no user info, not on https://accounts.aidenkt.com, and written exactly as a browser writes it (`new URL(x).href === x`) |
| `clientType` | optional, default `confidential`; `confidential` (a server with a secret) or `public` (a page or a mobile app: no secret, PKCE required) |

Palette keys for `color`: `aiden-red` (Aiden Red, #E41D4C), `id-coral` (ID Coral, #FF566A), `3ds-red` (3DS Red, #B5121B), `dragon-red` (Dragon Red, #7D3230), `strawberry-lemonade` (Strawberry Lemonade, #F76D53), `overwatch-orange` (Overwatch Orange, #FF7D25), `starlight-gold` (Starlight Gold, #DCA021), `berkeley-yellow` (Berkeley Yellow, #FBC81F), `website-lemon` (Website Lemon, #F4DD75), `glasses-green` (Glasses Green, #919762), `bot-green` (Bot Green, #5FE21D), `lax-mint` (LAX Mint, #B6EAB6), `reveria-green` (Reveria Green, #36FD98), `kt-green` (KT Green, #15DD7D), `matcha` (Matcha, #0A864C), `payback-pine` (Payback Pine, #14452F), `scout-teal` (Scout Teal, #08C6BE), `park-sky` (Park Sky, #5FE4FF), `airport-blue` (Airport Blue, #69A6FD), `stardust-blue` (Stardust Blue, #225FFD), `persian-sapphire` (Persian Sapphire, #0445DF), `space` (Space, #1C3249), `after-hours-indigo` (After Hours Indigo, #252452), `elementary-purple` (Elementary Purple, #3F0585), `turn-back-purple` (Turn Back Purple, #6728EF), `early-purple` (Early Purple, #7367FF), `coastal-lavender` (Coastal Lavender, #D4CDFF), `booster-orchid` (Booster Orchid, #E46BF4), `held-violet` (Held Violet, #DE5BB6), `disney-pink` (Disney Pink, #FC2F99), `creamsicle` (Creamsicle, #FCE3C8), `iced-latte` (Iced Latte, #755640), `mocha` (Mocha, #563A26), `tech-white` (Tech White, #FDFFFF), `house-white` (House White, #FBF7F0), `snow` (Snow, #BAC1CB), `titanium` (Titanium, #8F8A82), `iphone-gray` (iPhone Gray, #534D5B), `charcoal` (Charcoal, #4D4947), `black` (Black, #0D0D0D).

### `GET /api/agent/apps`

```sh
curl -s $KT/api/agent/apps -H "Authorization: Bearer $TOKEN"
```

`200 {"apps": [<app>, …]}`

### `POST /api/agent/apps`

```sh
curl -s -X POST $KT/api/agent/apps -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Notes","homepageUrl":"https://notes.example.com/","color":"early-purple",
       "origins":["https://notes.aidenkt.com"],"redirectUris":["https://notes.example.com/auth/callback"]}'
```

`201 {"app": <app>, "clientSecret": "kts_…"}` (no `clientSecret` for a public client).
Store the secret now; it is not shown again. Limits: 10 new apps an hour per agent, 30 a day
per person, 20 apps per person in all. Every request in this guide that sends an image also
counts against your person's share of the apps' upload budget (40 a day).

### `GET /api/agent/apps/:id`

```sh
curl -s $KT/api/agent/apps/$APP_ID -H "Authorization: Bearer $TOKEN"
```

`200 {"app": <app>}`

### `PATCH /api/agent/apps/:id`

Send only the fields to change. `origins` and `redirectUris` replace the whole list.
Changing `redirectUris` ends codes that were not yet exchanged.

```sh
curl -s -X PATCH $KT/api/agent/apps/$APP_ID -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"redirectUris":["https://notes.example.com/auth/callback","https://staging.notes.example.com/auth/callback"]}'
```

`200 {"app": <app>}`, plus `clientSecret` when `clientType` changed to `confidential`.
`position` (the order of Your Apps) is Aiden's alone: `403`.

### `DELETE /api/agent/apps/:id`

```sh
curl -s -X DELETE $KT/api/agent/apps/$APP_ID -H "Authorization: Bearer $TOKEN"
```

`200 {"ok": true}`. Deleting ends every sign-in, token, consent and in-app identity of the app.

### `POST /api/agent/apps/:id/secret`

Rotate the client secret. The old one stops working at once; access tokens already issued
keep working until they expire.

```sh
curl -s -X POST $KT/api/agent/apps/$APP_ID/secret -H "Authorization: Bearer $TOKEN"
```

`200 {"clientId": "ktc_…", "clientSecret": "kts_…", "rotatedAt": 1794779798000}`.
`409 public_client` for a public client. 10 rotations an hour per app.

### `POST /api/agent/apps/:id/icon`

The app's icon: exactly 512 x 512 pixels, WebP or JPEG, at most 400 KB, with no metadata
(no EXIF, XMP or ICC). Send the raw bytes with the matching Content-Type.

```sh
curl -s -X POST $KT/api/agent/apps/$APP_ID/icon -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: image/webp" --data-binary @icon.webp
```

`201 {"ok": true, "iconUrl": "https://accounts.aidenkt.com/media/a/…/….webp", "iconSpec": null}`. `415 invalid_image` with a
`reason`, `413 too_large`. 10 icon changes an hour per app.

An emoji icon is an image you draw yourself (the emoji on a palette colour, 512 x 512) and
upload this way with `?emoji=<one emoji>&background=<palette key>` (or `&style=white` on a
Tech White tile, with no background); the app then remembers
`iconSpec: {"kind": "emoji", "emoji": "…", "style": "filled", "background": "…"}`. `400 invalid_emoji` unless
the value is exactly one emoji.

### `PUT /api/agent/apps/:id/icon`

An icon from the icon pack on a palette colour. KT Accounts draws it (an SVG) and stores it.

```sh
curl -s -X PUT $KT/api/agent/apps/$APP_ID/icon -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"kind":"glyph","glyph":"ph:notebook","background":"early-purple"}'
```

`201 {"ok": true, "iconUrl": "https://accounts.aidenkt.com/media/a/…/….svg", "iconSpec": {"kind": "glyph", "glyph": "ph:notebook", "style": "filled", "background": "early-purple"}}`.
`style` is `filled` (the default: the palette colour behind, the glyph in Black or Tech White,
whichever reads on it) or `white` (black and white: a Tech White tile, a Black glyph and a
hairline edge; send no `background`). `400 invalid_style` for anything else.
`400 invalid_glyph`, `invalid_color`, `invalid_icon_kind`; `{"kind": "emoji"}` is
`400 emoji_needs_image` (draw it and upload it as above). Glyph keys:
`kt:mark`, `kt:passkey`, `kt:mail`, `kt:agent`, `kt:device`, `kt:phone`, `kt:shield`, `kt:key`, `kt:settings`, `kt:pencil`, `kt:sprout`, `kt:code`, `kt:person`, `kt:list`, `kt:check`, `badge:aiden`, `badge:sysadmin`, `badge:partner`, `badge:friend`, `badge:developer`, `badge:agent`, `badge:test`, `ph:notebook`, `ph:note`, `ph:note-pencil`, `ph:notepad`, `ph:pencil-simple`, `ph:pen-nib`, `ph:paint-brush`, `ph:palette`, `ph:calendar`, `ph:calendar-blank`, `ph:calendar-check`, `ph:clock`, `ph:alarm`, `ph:timer`, `ph:hourglass`, `ph:chat-circle`, `ph:chat-teardrop`, `ph:chats`, `ph:chat-centered-text`, `ph:envelope`, `ph:envelope-simple`, `ph:paper-plane-tilt`, `ph:phone`, `ph:video-camera`, `ph:microphone`, `ph:megaphone`, `ph:bell`, `ph:music-note`, `ph:music-notes`, `ph:headphones`, `ph:speaker-high`, `ph:vinyl-record`, `ph:playlist`, `ph:radio`, `ph:guitar`, `ph:piano-keys`, `ph:microphone-stage`, `ph:camera`, `ph:image`, `ph:images`, `ph:film-strip`, `ph:film-slate`, `ph:video`, `ph:play-circle`, `ph:television`, `ph:monitor-play`, `ph:map-pin`, `ph:map-trifold`, `ph:compass`, `ph:globe`, `ph:globe-hemisphere-west`, `ph:airplane`, `ph:car`, `ph:bicycle`, `ph:train`, `ph:bus`, `ph:rocket-launch`, `ph:boat`, `ph:mountains`, `ph:tree`, `ph:tent`, `ph:book`, `ph:book-open`, `ph:books`, `ph:bookmark`, `ph:bookmark-simple`, `ph:graduation-cap`, `ph:student`, `ph:exam`, `ph:lightbulb`, `ph:brain`, `ph:atom`, `ph:flask`, `ph:dna`, `ph:code`, `ph:terminal-window`, `ph:brackets-curly`, `ph:git-branch`, `ph:bug`, `ph:database`, `ph:cpu`, `ph:cloud`, `ph:cloud-arrow-up`, `ph:hard-drives`, `ph:plug`, `ph:wifi-high`, `ph:chart-bar`, `ph:chart-line`, `ph:chart-pie`, `ph:trend-up`, `ph:presentation-chart`, `ph:calculator`, `ph:currency-dollar`, `ph:wallet`, `ph:credit-card`, `ph:bank`, `ph:coins`, `ph:receipt`, `ph:piggy-bank`, `ph:game-controller`, `ph:dice-five`, `ph:puzzle-piece`, `ph:trophy`, `ph:medal`, `ph:crown`, `ph:sword`, `ph:target`, `ph:ghost`, `ph:alien`, `ph:heart`, `ph:star`, `ph:smiley`, `ph:smiley-wink`, `ph:hand-waving`, `ph:hands-clapping`, `ph:thumbs-up`, `ph:fire`, `ph:sparkle`, `ph:lightning`, `ph:sun`, `ph:moon`, `ph:cloud-sun`, `ph:rainbow`, `ph:snowflake`, `ph:umbrella`, `ph:drop`, `ph:leaf`, `ph:plant`, `ph:flower`, `ph:cactus`, `ph:paw-print`, `ph:dog`, `ph:cat`, `ph:bird`, `ph:fish`, `ph:butterfly`, `ph:house`, `ph:buildings`, `ph:storefront`, `ph:door`, `ph:key`, `ph:lock`, `ph:shield-check`, `ph:fingerprint`, `ph:user`, `ph:users`, `ph:user-circle`, `ph:identification-card`, `ph:folder`, `ph:folder-open`, `ph:file`, `ph:file-text`, `ph:files`, `ph:archive`, `ph:package`, `ph:tray`, `ph:clipboard-text`, `ph:list-checks`, `ph:check-square`, `ph:kanban`, `ph:shopping-cart`, `ph:shopping-bag`, `ph:gift`, `ph:tag`, `ph:ticket`, `ph:coffee`, `ph:pizza`, `ph:hamburger`, `ph:cooking-pot`, `ph:fork-knife`, `ph:wine`, `ph:beer-bottle`, `ph:cake`, `ph:ice-cream`, `ph:barbell`, `ph:person-simple-run`, `ph:heartbeat`, `ph:pill`, `ph:first-aid`, `ph:tooth`, `ph:gear`, `ph:wrench`, `ph:hammer`, `ph:magic-wand`, `ph:robot`, `ph:sliders`, `ph:lifebuoy`, `ph:info`, `ph:question`, `ph:link`, `ph:share-network`, `ph:rss`, `ph:newspaper`, `ph:article`, `ph:quotes`, `ph:translate`, `ph:hash`, `ph:at`, `ph:basketball`, `ph:soccer-ball`, `ph:football`, `ph:tennis-ball`, `ph:volleyball`, `ph:sun-horizon`, `ph:waves`, `ph:anchor`, `ph:lighthouse`, `ph:flag`, `ph:flag-checkered`, `ph:infinity`, `ph:circles-three-plus`, `ph:shapes`, `ph:cube`, `ph:diamond`.

### `DELETE /api/agent/apps/:id/icon`

```sh
curl -s -X DELETE $KT/api/agent/apps/$APP_ID/icon -H "Authorization: Bearer $TOKEN"
```

`200 {"ok": true, "iconUrl": null}`

## 4. Errors

Every error is JSON: `{"error": "<code>"}`, sometimes with `value` (the input that was
refused) or another field.

| Status | `error` | Meaning |
| --- | --- | --- |
| 400 | `invalid_request` | the body is not a JSON object |
| 400 | `invalid_name`, `invalid_slug`, `invalid_homepage`, `invalid_color`, `invalid_audience`, `invalid_client_type`, `invalid_position` | that field is not acceptable (see the rules above) |
| 400 | `invalid_origin`, `invalid_origins`, `origin_reserved`, `duplicate_origin` | an origin is malformed, the list is too long, the origin is KT Accounts' own, or it is listed twice |
| 400 | `invalid_redirect_uri`, `invalid_redirect_uris`, `duplicate_redirect_uri` | the same for redirect URIs |
| 400 | `homepage_not_registered`, `homepage_reserved` | the home page is not on one of the app's origins or redirect URI hosts, or it is on a site that is part of KT Accounts |
| 400 | `nothing_to_update` | a PATCH with no known field |
| 400 | `invalid_glyph`, `invalid_icon_kind`, `invalid_emoji`, `invalid_style`, `emoji_needs_image` | an icon from the maker is not acceptable (see the icon endpoints) |
| 400 | `reserved_permission` | a `role.*` permission was sent where permissions are granted (agents, invites, the permissions API): roles come only from badges |
| 401 | `invalid_token` | no access token, a key where a token belongs, or an expired or revoked token |
| 403 | `forbidden` | the token lacks `apps.manage`, or the action is Aiden's alone |
| 404 | `not_found` | no such app, or not one of your person's apps (the two are not told apart) |
| 409 | `slug_taken`, `origin_taken` | another app has it |
| 409 | `too_many_apps` | your person has 20 apps |
| 409 | `builtin_app`, `public_client` | the change does not apply to this app |
| 413 | `too_large` | the icon is over 400 KB |
| 415 | `invalid_image`, `unsupported_media_type` | the icon is not an acceptable image, or the body type is wrong |
| 429 | `rate_limited` | wait for `Retry-After` seconds |

Every dynamic route also answers 429 to more than 120 requests a minute from one IP address.

## 5. Integrate an app

Pick the path that fits where the app runs. Each app page in the portal
(https://accounts.aidenkt.com/developers/apps/<id>) shows these snippets filled in.

### A site on aidenkt.com: the sign-in kit

1. Register the site's origin, for example `https://notes.aidenkt.com`, in `origins`.
2. Put the kit in the page's `<head>` (current file names and SRI hashes: `GET https://accounts.aidenkt.com/kit/manifest.json`):

```html
<link rel="stylesheet" href="https://accounts.aidenkt.com/kit/kit.css">
<script src="https://accounts.aidenkt.com/kit/kit.js" defer></script>
```

3. Put `<div data-kt-account data-kt-label="Log In"></div>` where the button and picture go.
   The site's CSP must allow https://accounts.aidenkt.com in `script-src`, `style-src`, `font-src`, `img-src` and `connect-src`.

The kit draws the Log In button, the person's picture and menu, and the app switcher. Signing in
always happens on https://accounts.aidenkt.com; the person comes back to the page they were on.

### Read who is signed in from your own page (aidenkt.com origins)

```js
const r = await fetch("https://accounts.aidenkt.com/api/whoami", { credentials: "include" });
const me = r.ok ? await r.json() : null; // 401 means nobody is signed in
```

`me` has `id`, `displayName`, `handle`, `avatarUrl`, `bannerUrl`, `isOwner`, `permissions`,
`badges` and `apps`, and never an email address. What the page shows is presentation only:
check anything that matters on a server.

### Any domain: OpenID Connect

Discovery: `https://accounts.aidenkt.com/.well-known/openid-configuration`. Authorization code flow only; PKCE (S256)
is required for public clients and recommended for all. Scopes: `openid` (required),
`profile` (name, handle, picture), `email` (only when the account has a verified address and
the person ticks it on the consent screen), `permissions` (claims `permissions`, the person's
permissions with their `role.<badge>` ones, and `is_owner`, in the ID token and userinfo).

1. Make a code verifier (43 to 128 characters) and its S256 challenge, a `state` and a `nonce`.
2. Send the person to:

```
https://accounts.aidenkt.com/oauth/authorize?client_id=ktc_…&redirect_uri=https%3A%2F%2Fnotes.example.com%2Fauth%2Fcallback
  &response_type=code&scope=openid%20profile&state=STATE&nonce=NONCE
  &code_challenge=CHALLENGE&code_challenge_method=S256
```

   They sign in on https://accounts.aidenkt.com if they are not already, and allow the app once. They come back to the
   redirect URI with `code`, `state` and `iss`, or with `error` (`access_denied` when they
   chose Cancel). Check that `state` is yours.
3. Exchange the code within 60 seconds. It works once.

### `POST /oauth/token`

```sh
curl -s $KT/oauth/token -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=authorization_code --data-urlencode code=$CODE \
  --data-urlencode redirect_uri=https://notes.example.com/auth/callback --data-urlencode code_verifier=$VERIFIER
```

A public client sends `client_id` in the form and no secret. The body must be
`application/x-www-form-urlencoded`.

```json
{ "access_token": "kto_…", "token_type": "Bearer", "expires_in": 3600, "scope": "openid profile", "id_token": "eyJ…" }
```

There is no refresh token: send the person through step 2 again; it is instant once they have
allowed the app. Errors follow RFC 6749: `invalid_request`, `invalid_client` (401),
`invalid_grant`, `unsupported_grant_type`.

4. Check the ID token: ES256, signed by a key in `https://accounts.aidenkt.com/oauth/jwks.json`, `iss` = `https://accounts.aidenkt.com`,
   `aud` = your client ID, `nonce` = yours, `exp` in the future. `sub` is the person's
   KT Accounts ID.

### `GET /oauth/userinfo`

```sh
curl -s $KT/oauth/userinfo -H "Authorization: Bearer $ACCESS_TOKEN"
```

`{"sub": "…", "name": "…", "preferred_username": "…", "picture": "…"}` for the scopes the person allowed.

### `PUT /oauth/identity`

Tell KT Accounts who the person is inside your app. It shows on their Account Settings page
next to your app, and nowhere else. Authenticated with the person's access token for your app.

```sh
curl -s -X PUT $KT/oauth/identity -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
  -d '{"displayName":"Aiden","handle":"aiden","profileUrl":"https://notes.example.com/@aiden"}'
```

`displayName`: up to 50 characters. `handle`: 1 to 40 of letters, numbers, `_`, `.` and
`-` (a leading @ is dropped). `profileUrl`: https, on a host your app registered (an origin or
the host of a redirect URI). `null` clears a field; a field you leave out is kept.
`200 {"identity": {…}}`. 30 changes an hour per person.

### `DELETE /oauth/identity`

```sh
curl -s -X DELETE $KT/oauth/identity -H "Authorization: Bearer $ACCESS_TOKEN"
```

### `PUT /oauth/identity/picture`

The person's picture in your app: 512 x 512 WebP or JPEG, at most 400 KB, no metadata. It is
stored by KT Accounts; nothing is fetched from your site.

```sh
curl -s -X PUT $KT/oauth/identity/picture -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: image/webp" --data-binary @picture.webp
```

### `DELETE /oauth/identity/picture`

```sh
curl -s -X DELETE $KT/oauth/identity/picture -H "Authorization: Bearer $ACCESS_TOKEN"
```

## 6. What you cannot do

- Read people's data: there is no agent endpoint for consents, in-app identities or users.
- Suspend or resume an app, reorder Your Apps, or change a built-in app (Aiden only, in person).
- Give yourself permissions or badges.

A person removing your app in Account Settings, Aiden suspending it, or deleting it ends its
tokens and codes at once.
