---
name: mimi-onboarding
description: The mimi agent's contract — how a person says a sentence is for mimi (choosing her in the box they already type in, or something reading the sentence and deciding, or both), where the conversation should live given what the app already has (no chat, a chat with its own agent, or a chat that already routes between several), the address to talk to, what the app sends on every request (the catalog of what it knows how to paint, how it looks today, the shared brand if any), the wire format of the stream it answers with, and exactly what it returns. Writes the code that connects an app to the agent. Use when someone says "connect my app to mimi", "add mimi to our chat", "add mimi to the agent list", "let me pick which agent I'm talking to", "let people change the look from the chat", "how do I talk to the mimi agent", "build the catalog for mimi", "what does it send me and what do I send it", or wants to understand the contract before writing code.
---

# mimi's contract

mimi is an agent that understands plain-language appearance requests — *"make it green"*, *"a bit more rounded"*, *"go back to how it was"* — and returns new values plus a sentence for the person. It is not a service you ask for a color: it is a model that **reasons** over what your app tells it it knows how to paint, and that is why most of this guide is about that account, not about the request itself.

**mimi does not enter your app.** It doesn't hold your credentials, doesn't talk to your database, and doesn't save your theme anywhere. Everything it knows about your application is what you send it **inside every turn**; everything it does with the answer is up to you. (It can, if you ask it to, keep a file with your client's **brand decisions** — that is a different object, it paints nothing, and it lives in §2.3.1.)

```
   your app                                    mimi
     │                                          │
     │  "here's what I know how to paint,       │
     │   how I look today, and what the         │
     │   person asked for"                      │
     ├─────────────────────────────────────────►│
     │                                          │  thinks
     │                                          │
     │  "these are the new values,              │
     │   and this is what to tell the person"   │
     │◄─────────────────────────────────────────┤
     │
     ├───► you decide what to paint
     └───► you decide what to save and where
```

This guide covers five things, in this order: **how the conversation reaches mimi and where it lives**, **how to talk to it**, **what you send it** (the catalog is the heart of all of this), **what it hands back**, and **what it understands and what it doesn't**. Where you save what comes back — a document per person, a row in your database, a cookie — is your call. mimi doesn't ask and doesn't need to know. There is no question about that; the ones there are live in §0.3 and in the catalog, and none of them changes this.

> **Sections 1 to 6 are the contract.** Every wire-format detail and every
> quoted response in them was verified against the live service, turn by turn.
> If something there disagrees with what you observe, trust what you observe
> and fix this file — see *Feeding this back*, last section.
>
> **Section 0 is not that.** It is design judgement about where a conversation
> belongs in a product, drawn from the handful of applications this guide has
> met. It is written in the same voice as the rest and has nowhere near the
> same backing: a claim there can be right for every app so far and still be
> wrong for yours. Read it as advice with reasons, and argue with it.
>
> The worked examples use one particular application's variable names
> (`--brand-tint`, `--surface-alt` and so on) because they are real measured
> turns, not invented ones. **Yours will be called something else, and that is
> the normal case** — nothing here expects a naming convention.

---

## 0 · First decide how the conversation reaches mimi, and where it runs

**Do this before writing a line.** mimi answers turns; it brings no interface
with it. Something in your app has to carry the person's sentence in and paint
the answer, and *what that something is* depends on what your app already has.
Getting this wrong doesn't show up as a bug in a diff — it shows up as an app
with two places to talk to and no rule about which one to use.

There are **two** decisions here, and only the second one has a tree. The
first is how a sentence gets addressed to mimi at all; the second is where the
turn physically runs. They are independent — every combination of the two is
buildable — and skipping the first is how an integration ends up owning a
classifier nobody chose.

### 0.1 · How does the person say that this one is mimi's turn?

Two mechanisms, and the mistake is reading them as alternatives:

```
  EXPLICIT — the person addresses it        IMPLICIT — something decides
  ─────────────────────────────────         ───────────────────────────
  picks mimi from the composer's list       reads the sentence and picks
  types a prefix, or an @name               an owner for it
  is already on a screen that is mimi's

  deterministic: nothing to get wrong       nothing to learn: it just works
  costs discoverability — nobody types      costs a boundary that has to be
  a prefix they don't know exists           declared, tested and kept true
```

**Ask it like this, and wait for the answer.** Write it in the scene, not in the
mechanism: whoever answers has no reason to know what a composer is, or a
boundary between agents.

> **Someone wants to ask mimi for an appearance change from your chat. What
> mechanism are you going to build so that request reaches it?**
>
> **a · They can pick it, and the chat also recognizes it on its own.** *(the one
> that usually wins)* The person can name it — pick it from a list, type its name
> up front — and if they don't, the agent you already have notices the request is
> about appearance and hands it over. It is building both; in exchange nobody
> gets lost: whoever knows picks, whoever doesn't still arrives.
>
> **b · Only picking it.** Nobody ever misses the destination and there is no
> boundary to maintain. What it costs: someone who doesn't know it exists types
> *"make it darker"*, the wrong agent answers, and **nothing happens** — no
> error, no warning, and nothing telling them why.
>
> **c · Only the chat recognizing it.** Nothing to learn: people type normally
> and the request arrives on its own. It costs two things: a boundary between
> agents you have to declare, test, and re-test every time a new one shows up;
> and every request going through two models in a row, so it takes longer.

Here you **may** mark which one you recommend, unlike §0.3: this is an opinion of
ours about design — measured and arguable — not a fact about their product that
only they hold.

**And the good answer is almost always both, in that order** — explicit as the
path that cannot misroute, implicit as the fallback for the person who doesn't
know mimi exists. Measured on the front door that already routes four agents
in this ecosystem: *three* deterministic layers run before its classifier ever
does. An agent the caller named outright wins immediately — and if that agent
doesn't serve that app the turn is refused rather than handed to a different
one, because answering as somebody else is the failure the whole thing exists
to prevent. Then an `@name` written into the sentence. Then a keyword list.
The model is only asked when all three are silent.

**Look at your composer, not only at your backend.** The tree below asks what
your chat's backend talks to, and it will answer honestly without ever
mentioning that the box people type into already has a gesture for choosing a
destination. Measured, in an app this guide integrated: the panel behind the
composer's `/` already rendered a labelled group of tools and carried a
comment reserving a second labelled group for agents — the exact affordance
the reader afterwards said they had expected, sitting empty in a file the
integration never opened. No tree can see that. Open the composer yourself and
ask what gesture already exists for *who is this for*.

Building only one of the two is a legitimate choice; building only one by
accident is not. Build only the implicit path and you own a boundary forever —
case B below is how to build one that holds. Build only the explicit one and
*"make it dark"*, typed with no prefix, reaches whoever owns the bar and
nothing happens.

### 0.2 · Where does the turn run?

Look first, then pick:

```
  does the app already have a chat?

     no ─────────────► A. you are adding the surface
     │
    yes
     │
     ├─ do you control what it talks to?
     │        │
     │        no ──────────────────────────► D. treat it as A, out loud
     │        │
     │       yes
     │        │
     │        ├─ ONE agent of its own ─────► B. that agent hands the request over
     │        │
     │        └─ already routes to several ► C. mimi is a roster entry, not code
```

**Answer the "do you control it?" question before the other one**, because a
chat that is merely *present* looks identical to a chat that is *yours*. The
first real app this guide met had a chat widget embedded from a remote bundle,
pointed at a router that already knew about mimi — case C on sight. It was not:
the roster lived in someone else's deployment, and a correct call to that
router came back asking for a signed credential the app had no way to mint.
Case C was unreachable, and the chat that was already on the page had been
dead for that app all along.

#### A · No chat yet

Nothing to reconcile. Add whatever surface fits the product — a panel, a
dock, a full chat — and go on to §1. The rest of this guide is about the turn
itself, which is identical in all three cases.

#### B · There is already a chat, and it has its own agent

**This is the one that gets built wrong, and the wrong version demos fine.**

The tempting move is to put a second box next to the existing chat, "just for
appearance". Don't. Two conversational surfaces in one app is a product
defect: the person has to know which box a given sentence belongs in, and the
same words typed into the other one either fail or do something else
entirely. They will type *"make it dark"* into whichever one they happen to
be looking at.

**But a second box is not the only alternative, and rejecting it is not an
argument for the classifier.** Letting the person address mimi *inside the box
that is already there* — a name in the composer's own picker, a prefix, an
`@name` — is neither a second surface nor a guess: one conversation, one place
to type, and a destination the person stated. If your composer already has a
gesture for choosing a destination, or has room for one, build that first
(§0.1) and read the rest of this case as how to build the fallback for
everyone who doesn't use it.

What works when nothing was addressed: **the agent already in that chat
recognises an appearance request and hands the sentence to the app**, and the
app runs the mimi turn. Four rules, each of them a real failure if skipped:

- **Do not give the existing agent a mimi client.** It doesn't have the
  catalog, doesn't know the values this person is currently on, and has no
  browser to paint into — all three live on the app side (§2.1, §2.2).
  Wiring mimi in over there means copying all three across and watching them
  go stale. The agent should hold none of it.
- **Hand the sentence over close to verbatim, in the language it was
  written in.** Not colour values, not a tidy paraphrase, not a translation.
  mimi does its own understanding, and that reasoning is the entire reason
  it's worth calling — pre-digesting the request throws it away.
- **The host agent must not say what changed.** It speaks before anything has
  happened, and mimi routinely applies part of a request and refuses the rest
  (§5). A host answering *"done, it's green now"* and a theming agent
  answering *"I changed the colour, but this app can't do shadows"* are two
  claims in one conversation, and the person believes the first one. Have the
  host acknowledge that it passed the request on, and show mimi's own
  sentence as the answer.

  **If your chat shows one message at a time, show only mimi's.** That advice
  assumes a scrolling transcript. A dock bar with a four-second reply preview,
  or a toast, has room for exactly one sentence about one turn — and an
  acknowledgement that fades before the real answer arrives is worse than
  nothing. Drop the host's line for this case, the same way most of these
  chats already drop it for a navigation. Doing it in the client is also
  stronger than asking the model nicely: the host agent's text simply never
  reaches a surface, whatever it decided to say.
- **Declare the boundary in the host agent's own tool description — and
  nowhere else.** Which requests are appearance and which aren't is a rule
  the model reads from that text. Say explicitly what the tool is for
  (colours, dark/light, roundness, borders, spacing) and what it is *not*
  for (an avatar or any image, the app's own data, its layout).

  Verified: a description that specific is enough on its own. An agent whose
  entire stated identity was *"you govern the agent registry"* still routed
  *"cambia los colores de esta pagina a verde oscuro"* to the tool, and
  still left *"muestrame los agentes activos"* and *"cambia el avatar del
  agente mimi"* alone — with appearance mentioned nowhere in its system
  instructions. Resist adding a second copy of the rule there "to be safe":
  it buys nothing measurable, and two statements of one boundary are exactly
  the pair that drifts apart later.

  **A prose clause is not enough for the negative half.** On that same agent,
  a description that named layout in a comma-separated "it is not for…"
  sentence — exactly as recommended above — still sent *"move the search box
  to the top of the page"* into the appearance tool. What fixed it was
  spelling the exclusions out as their own list, each with real phrasings a
  person would use:

  ```
  Do NOT call it for any of these, even though they sound visual:
  - WHERE something sits, what ORDER things are in, or which elements exist —
    "move the search box to the top", "pon los filtros a la izquierda",
    "make the table wider". Nobody can change the layout. Say so plainly.
  - Any IMAGE. …
  ```

  After that, three layout phrasings in two languages all came back as a
  plain text refusal with no tool call at all.

  **"A logo" has two right answers, and one line cannot hold both.** In an app
  that stores an avatar per record, *"change mimi's avatar"* is the host
  agent's own data and *"hazme el logo mas grande"* is a fixed asset nobody
  can change. Collapsed into one clause ("an avatar, a logo, or any image —
  use the detail page"), the second phrasing fell through to appearance,
  because the detail page it was pointed at obviously wasn't the answer. Split
  them: name the one image that IS your data and where it lives, then say
  every other image is fixed.

**Test the boundary in both directions before calling it done**, with real
turns: one request that must reach the tool, and two that must not — the
nearest thing the host agent legitimately owns, and something that sounds
visual but isn't (an avatar, a logo, the page layout). Testing only that it
fires passes happily on an agent that has quietly started sending everything
to appearance.

You do not need the whole stack for this. Calling the model directly with the
host agent's real system instruction and real tool declarations, and printing
which tool it picked, is a twenty-line script and about a dozen cheap turns —
and it is the only part of this integration where "it worked once" and "it
works" are genuinely different claims. Twelve phrasings across two languages
caught two routing bugs that every unit test in the repo was happy with.

For the mechanics of the hand-over, **use the pattern that app already has**
for "the agent decided something, the browser acts on it". Most chats that
can already navigate a user have one — usually a marker the client turns into
an event. Adding a second mechanism beside it is how two things that do the
same job start disagreeing.

The cost, stated plainly: two models run in series, so the turn takes about as
long as both, and the boundary above is yours to keep true every time an agent
is added. That is the price of a sentence **nobody addressed** reaching the
right agent anyway — not the price of one conversation, which an addressed
turn buys for nothing.

#### C · The chat already routes between several agents

Then this is configuration, not code: mimi joins the roster that front door
already reads, and the turn reaches her the way the other agents' turns do.
Three things to check before calling it done, all of which fail silently:

- **Check that the front door still lets a person address mimi outright, and
  that your surface offers it.** A roster whose only way in is a classifier is
  case B's boundary problem with more hops and someone else's deployment in the
  middle. And if the front door does support a name or an `@name` while your
  composer never shows it, the path that cannot misroute exists and nobody can
  reach it.
- **A roster usually exists in more than one place** — what runs on a laptop
  and what runs deployed are different files. An entry added to only one of
  them works perfectly in front of you and does not exist for anyone else.
  **Compare the two files entry by entry, not just "is my app in both".** The
  sharper failure is an app that appears in both with a *different roster on
  each side*, so nothing looks missing. Measured on a real router: of six apps
  declared locally only three existed in the deployed file, and of the ones in
  both, two carried an extra agent in production that was not running locally
  at all. A request that reaches mimi on your machine can reach a different
  agent entirely for a user, which means testing the routing locally proves
  nothing about where the same sentence lands.
- **The boundary between agents is declared once per agent**, in each one's
  own instructions, and every copy has to say the same thing. When they
  disagree, a request gets routed to one agent and answered by another, or
  bounces between them.

#### D · The chat on the page is not yours

A widget loaded from someone else's bundle, pointed at someone else's router.
You cannot add a roster entry you cannot deploy, and you usually cannot mint
the credential it expects. **Build your own surface, as in case A** — but
first find out whether that embedded chat still works *for this app*. Usually
it does not, and then you are not adding a second surface, you are replacing a
dead one.

Two things to do that case A does not ask for. **Check before you assume**:
send that router one well-formed request and read what comes back; "it is on
the page" is not evidence that it works. And **say what you did, in writing**
— a README line, a comment where the tag used to be. Somebody put that widget
there on purpose, and a silent removal reads as vandalism to whoever finds it
next.

### 0.3 · Two questions to ask the person, not infer from the code

**Ask these before writing a line, and wait for the answer.** There are two, and
they are **independent**: always ask both. An app with no organizations may still
want the per-person file, and skipping that question because the first was
answered "no" hides a whole feature from someone who would have used it. Neither
can be guessed by
reading the repository: the first is a product decision and the second is a
signed value from that app's identity system. Grepping for something called
`admin` and wiring it up is how you arrive at a confidently wrong answer.

**And do not mark any option as recommended.** Saying what you found by reading
the code — "I saw no organization entity" — is fine and saves time; turning it
into the suggested answer is not. The person confirms your deduction with one
click, and you decided it from the repository after all: the question became
decoration. The answer lives **outside** the code — in the product and in the
identity system — and it belongs to whoever is on the other side, who also knows
things the repository doesn't show.

> **1 · A file with each company's brand decisions**
>
> Every time someone asks for a change, we can write it into a standard text
> file — one per organization: the value, and a line saying what it is for. We
> create it the first time and keep it current on our own.
>
> **What having it buys you:** that same file can dress a different
> application, with different variable names, without copying a single color by
> hand — because it doesn't only store values, it also stores what each one is
> for. It is what makes your client's brand theirs and not one app's.
>
> **What it is not:** it paints nothing, it is not what your app reads to draw
> itself, and it does not replace what you store.
>
> **You do not write that file and you do not send it to us.** You send us a
> short identifier — `acme-design-brand` — and we draft and maintain the document
> ourselves. Your app does not generate it, version it, or store it.
>
> For that it has to be able to send us, on every turn: **who the organization
> is**, an **already verified** value saying whether the person speaking
> administers its brand, and that **identifier**.
>
> Can it? · yes · no · we have no organizations

> **2 · And the same file per person** *(answered on its own — it applies whether
> you have organizations or not)*
>
> The same thing, one per person, for what each one asked for on their own. We
> write and maintain that one too, and here the identifier **is** the person's:
> no separate one is needed.
>
> **What having it buys you:** what that person asked for follows them. It is
> layered concept by concept on top of their company's brand — if there is one —
> so they keep their tweak and a rebrand still reaches them in everything they
> didn't touch. And in another application that asks with the same identifier, it
> follows them there too.
>
> For that, your app has to be able to send us a **stable identifier of who is
> speaking**.
>
> Can it? · yes · no
>
> If this one is no and the one above was yes: only people who administer the
> brand may reach the chat. Without knowing who is speaking, everything asked
> for is stored as the company's, and an ordinary person will get a sentence
> that doesn't match what was saved.

**If they don't answer, build the bare integration** — no brand block, as if the
answer to the first had been "we have no organizations". "I don't know" has to
mean the feature off, never half-wired: a brand declared without knowing who
each person is sends every request to the company's layer. See §2.3.1 for the
exact shape of all this.

The same goes for the **catalog**, which is the third thing that gets shown and
waits for an answer. If nobody answers, save it anyway **and say so in one line**
at the top of the file: that nobody approved it, and which decisions were taken
alone. It is the piece that most shapes the outcome of every turn that follows;
treating it as approved in silence is the most expensive way to move forward.

A note on what actually depends on what, because both questions travel in the
same block and it is easy to read them as chained: **they are not**. A person's
layer is resolved whether or not there is an organization — the case of an app
with no separate companies, where the only thing that exists is what each person
asked for, is covered. What does need to know who the organization is, is what
mimi **says**: without it, it cannot frame its sentences in a shared brand. The
per-person file hangs off nothing.

---

## 1 · How to talk to it

### The address, and why it is not for the browser

```
https://mimi.linexrewards.com/
```

One URL at the root: no per-resource routes, no version in the path. It is not one *protocol*, though — that same URL answers to two, and this guide describes one of them. Read that section below before you write the client.

**Call it from your server, never from the person's browser.** This isn't a restriction you need to request access around: the service sends **no** origin header at all, so a browser cuts the call off in the preflight before anything leaves. No credential is required either — the door asks for none — but that openness is exactly why the request has to leave from your side: anyone with the address can spend the model's quota, and deciding whether a user gets to ask for something is your call, not mimi's.

```
    ✗  browser ───────────────► mimi        no origin header: the browser cuts it off on its own
    ✓  browser ───► your server ───► mimi    your server decides who can ask, and forwards it
```

#### If the app has no server, building one is the job

Say this out loud early, because it is easy to read "call it from your server"
as a detail and discover halfway through that the app is a static page with no
server anywhere. There is no browser-side escape hatch: the preflight fails
before a request leaves, and even if it didn't, an endpoint that asks for no
credential and spends model quota cannot be reachable from a page's own
JavaScript.

Measured on a real static site, **standing up that server was about 70% of the
whole task** — more than the catalog, the client and the painting put
together. Plan for it: a request handler that holds the catalog, decides who
may ask, and streams the answer back. It does not have to be big, but it has
to exist, and something now has to deploy it.

**Your `app_id` needs no registration.** mimi accepts an id it has never seen and answers normally — it is a label on the turn, not an account. Nothing to request before you start. It is a client-supplied string that ends up interpolated into a URL further down the chain, so keep it to `^[a-z0-9][a-z0-9-]{0,63}$` — no slash, `@`, `#` or `..`. **That charset is entirely your side's job**: measured, `app_id: "bad/id"` is accepted and the turn runs normally, so nothing upstream will catch it for you.

### That address answers to TWO protocols, and this guide documents one

This will cost you an afternoon if nobody tells you. The same URL serves both:

| method | this guide | error for a missing `messageId` |
|---|---|---|
| `SendStreamingMessage` | **use this one** | `-32603` · `message.messageId is required for streaming.` |
| `message/stream` | do not | `-32602` · `message.messageId is required` |

Two different codes for the same mistake: they are genuinely two code paths,
not one endpoint being lenient. `message/stream` is A2A's own standard name,
so it is the first thing anyone who knows the protocol will reach for, and it
**answers 200 and works** — in a shape where everything in §3 is wrong:

- states arrive as `working` / `submitted`, not `TASK_STATE_WORKING`
- parts carry `kind: "text"`, and **every object carries a `kind` field** —
  the exact opposite of what §3 says about telling artifacts apart
- `role` must be `"user"`; sending `"ROLE_USER"` is a hard
  `-32602 message.role must be "user" or "agent"`

So: send `SendStreamingMessage`. If you find yourself reading a `kind` field
and it is working, you are on the other protocol and the rest of this guide
does not describe what you are holding.

**And do not pick the method by reading the versions in the public document,
because they lead you to the other one.** That document declares two interfaces
— `1.0` and `0.3`, at the same address — and its root says
`protocolVersion: "0.3"`, the lower of the two. That is correct and deliberate:
the `0.3` entry exists so a strict client's negotiation passes, and the root
fields are how an older client discovers the address. But **the interface the
service actually implements is `1.0`**, and its method is the one in this guide.
Read in good faith, that root `0.3` pushes you toward that version's standard
name — `message/stream` — which is exactly the one not to use, and which will
warn you about nothing because it answers 200.

### The request: JSON-RPC, one complete turn

```json
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "SendStreamingMessage",
  "params": {
    "message": {
      "messageId": "m-38a1",
      "contextId": "user-42-conversation",
      "role": "ROLE_USER",
      "parts": [{ "text": "make it green" }]
    },
    "metadata": {
      "app_id": "your-app",
      "context": { "theming": { "catalog": {}, "current": {}, "brand": null } }
    }
  }
}
```

Four fields that, written differently, don't fail with a clear error:

- **`messageId` is required.** Without it the request doesn't run, but the error arrives **with HTTP 200** — if your code only checks the status, it reads as success. The exact body:
  ```json
  {"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"message.messageId is required for streaming."}}
  ```
  Always check for `error` in the body, whatever the status says.
- **`role` has to be exactly `"ROLE_USER"`**, in that case. `"user"` doesn't break anything — the turn still runs — but the role ends up unrecognizable in the history. It's not the error that warns you; it's an oddity you'll see much later.
- **`app_id` and `context` go in `params.metadata`, NOT in `message.metadata`.** Putting them in the wrong place gives no error at all: the turn answers 200 and finishes fine, but mimi answers blind, with no catalog and no idea how your app looks today.
- **`contextId` is the only thing that gives the turn memory.** Omit it and every request is a new conversation — *"now a bit darker"* has nothing to compute against. Use the same value for every turn of one person's conversation. **Build it on your server from who the caller actually is**, never pass a browser-chosen value straight through: mimi keeps one conversation per `contextId` in its own process memory and asks for no credential, so two people who land on the same string share a conversation — by collision or on purpose. `${yourApp}:${hash(userId)}:${theirConversationId}` costs one line and the failure it prevents is invisible.

The response is a stream of events (`text/event-stream`), not a single JSON blob. Section 3 gives its exact shape — **read it before writing the parser**, because the envelope is not the obvious one.

### The ceiling

- A request larger than **100 KB** is rejected with a **413 in HTML**, not a protocol error — if your code expects JSON-RPC there, it blows up parsing it. A generous catalog is nowhere near that: a 23-token one serialises to about 4.6 KB with `current` included.
- Turns observed against a real catalog land between **2 and 8 seconds**. The agent cuts off at **90 seconds** if the model doesn't answer; budget your socket a little above that, not below.
- Conversation memory lives in mimi's process RAM, not a database. A service restart wipes it entirely — there's nothing to migrate on your side, the next turn with that `contextId` simply starts fresh.

### Its name and its face: don't ask us for them, they are already published

This gets asked over email every time, and it doesn't need to be. There is a public document, no credential required, carrying the agent's whole identity:

```
GET https://mimi.linexrewards.com/.well-known/agent-card.json
```

What you need from it to build the interface:

| field | what it is |
|---|---|
| `skills[0].name` | "Styles & design" — the **display** name |
| `name` | `mimi` — the identifier, not a name to show |
| `description` | "Colors, typography & themes." — one line, for a menu subtitle |
| `iconUrl` | **the address of its face**, ready for an `<img src>` |
| `provider` | who makes it: Linex Travel |
| `documentationUrl` | the page explaining what this is, for a person |

**The icon comes as an address, not as an inlined file.** Use it as is:

```html
<img src="https://mimi.linexrewards.com/assets/avatar.svg" alt="mimi" referrerpolicy="no-referrer">
```

It is a ~4.7 KB SVG, no credential and no origin restriction, so it works embedded from your own domain. If you need it inlined — to put it in your bundle, for an offline environment — derive it yourself:

```bash
curl -s https://mimi.linexrewards.com/assets/avatar.svg | base64 -w0
```

**The reverse is impossible, and that is why the document publishes the address:** from a base64 you cannot recover the address, while from the address you get both forms. If your client already has a scheme allow-list for what it paints — and it should — accept both `https:` and `data:image/`: each agent picks its own form and may change it without telling you.

**And mimi itself will hand you both, depending on where you point.** The form is decided by the SCHEME of the address you fetched the document from: over `https` you get the address; against a **local** mimi, which runs over `http`, you get the drawing embedded — some 6,000 characters in that field. It is not an inconsistency: over `http` an address would not work, because a secure page will not load insecure images and the filter would leave it empty — you would see a generic orb instead of the face and debug it as "the avatar broke". **Accept both forms and hardcode neither**: dropped as-is into the image tag, both render. If you are integrating locally and find base64 where you expected an address, it is neither your mistake nor this guide's.

⚠️ **That document is for discovery, not for talking.** The turn's address is in there too (`url` and both of `supportedInterfaces`, all three identical), but with this guide in hand you don't need to read them: it is the same one above. And it declares no credential because the turn asks for none — which changes nothing about the warning above on calling it from your server.

---

## 2 · What you send it: the `theming` block

Everything goes inside `params.metadata.context.theming`, with three pieces. None is required in the sense of the turn crashing without it — but without the first one, mimi **validates nothing** the model proposes.

### 2.1 · The catalog: what your app knows how to paint

This is the piece that changes the outcome the most, and the only one nobody outside can write: only whoever knows the application from the inside knows which token is which.

```js
{
  "catalog": {
    "appId": "your-app",
    "version": "1.0.3",
    "capabilities": {
      "tokens": [ /* see below */ ],
      "contrastPairs": [ { "text": "--ink", "bg": "--paper" } ],
      "classes": []
    },
    "varKeys": ["--brand", "--paper", "--ink", "--on-brand"],
    "componentProps": []
  }
}
```

**The minimum for it to count as a catalog** (if you don't meet it, mimi treats it as if there were no catalog at all — see the most expensive trap, below): `capabilities.tokens` has to be a **non-empty** array, and at least one element has to have a real `key`, or `varKeys` has to carry at least one key.

A token, field by field:

```js
{
  key: "--brand",                  // the EXACT name of your variable
  type: "color",                   // "color" | "dimension" — anything else is NOT validated
  kind: "accent",                  // surface | onSurface | accent | status | border
  concepto: "color-principal",     // optional, see 2.1.4
  label: "Primary color",          // what the model is told it's called
  description: "...",              // does NOT reach the model — only a brand document's prose
  group: "brand",                  // groups the catalog in the prompt, cosmetic
  aliases: ["primary", "brand", "brand color"],
}
```

#### 2.1.1 · `type`: only two values do anything

mimi's code branches on exactly two: **`color`** (requires a valid CSS color — hex, `rgb()`, `hsl()`, `oklch()`, or a name) and **`dimension`** (requires a CSS length — number + `px`/`rem`/`em`/`%`/etc., `calc()`, or `0`). Any other literal — `shadow`, `text`, `font` — **validates nothing**: the value only passes through the usual blocklist (never `url()`, `@import`, `expression()` or `javascript:`).

**There is no type for a shadow, and that is a real decision you have to make.** Section 4 lists shadows among the families mimi can change, but a `box-shadow` is neither a color nor a length, so any token you declare for one accepts *any string at all*. A single bad value takes out every shadow in the app with nothing to catch it — and if the shadow variable is composite or references another variable, that is a very visible break. Unless you have your own validation on the way in, **leave shadow tokens out of the catalog**. mimi then says so in words, which is honest and safe:

> *"I've made all the corners much rounder. Please note that customizing shadows is not supported at the moment."*

**Do not count on that being the reason it gives.** The same request, against a
catalog with no shadow tokens and `brand: null` / `hasBrand: false` /
`own: true` — an app with no organisations at all — came back:

> *"I've made the corners much rounder. I couldn't save the shadow preference
> because you don't administer the organization's brand."*

Both halves of that are wrong: there is no organisation, and the reason is
that the catalog has no shadow token. The refusal itself is still correct
(no shadow key in the `action`), so nothing downstream can tell. Re-running
the identical request against the identical catalog gave the honest version
instead — *"Please note that we cannot customize shadows at the moment"* — so
this is not a setting you got wrong, it is a coin flip you cannot see. This is the
same phantom-organisation copy §2.2 warns about for a reset — but it is not
confined to resets, it can attach itself to **any** refusal. Read §2.2's note
as a general property, not a reset-only quirk.

And there's a second consequence of `type`: **only `type: "color"` tokens receive a value when mimi derives a full palette** (*"a dark theme", "something more elegant"*). Dimensions are never derived — verified: a "make it dark" turn against a catalog with four radius tokens returned all four unchanged, and the save request didn't mention them at all. If a request touches shape, the model has to decide it explicitly, which it does perfectly well when asked.

**Appearance is more than colour, and a catalog of nothing but colours quietly halves what mimi can do for you.** Six families are on the table, and the middle three are the ones people forget to declare: the corner radius behind your `border-radius`, the elevation behind your `box-shadow`, the typeface behind your `font-family`, plus spacing and borders. A person who asks for a different typeface and gets nothing has no way to tell an agent that cannot from an app that never said it could. Declare what you have — with the caveat about `box-shadow` above, which has no `type` that validates it.

#### 2.1.2 · `kind`: what the token IS, not what it's called

When someone asks for a **mood** change — *"make it dark"*, *"something warmer"* — mimi doesn't guess from the variable's name: it looks at `kind`. The five values the code hands out are `surface`, `onSurface`, `accent`, `status` and `border`.

**If NO token in your catalog declares `kind`**, derivation falls back to a by-name rule that only recognizes the names of one app in the ecosystem (`background`, `foreground`, `primary`…). With your own names that matches nothing, and the result is a theme that changes halfway while the sentence says it changed entirely. **This has already happened in production.**

And the other way around: as soon as **a single** token in your catalog declares `kind`, derivation switches to the by-role path, and there **every token without `kind` is left out** of that derivation — it never receives a value, even if it is `type: "color"`. It's all or nothing; there's no mixed mode.

**`surface` is a positional ramp.** What reads as page background, what as card, what as raised is handed out in the order `surface` tokens appear; the first is the base. Alphabetizing the array silently reassigns the steps. Verified on a "make it dark" turn — declared in the order `--bg`, `--surface`, `--surface-alt`, they came back `#0f0f15`, `#181922`, `#1d1d28`: darkest first, stepping up exactly as declared. Reproduced on a second app declaring the same three names, same result.

**Declare them by role, not by today's lightness.** The two readings come
apart on any palette that isn't already a straight ramp. In one measured app
the light-mode values are `--bg #faf9f5`, `--surface #ffffff`,
`--surface-alt #f2f4f7` — the "raised" one is the *darkest* of the three,
because it is a recessed panel, not a raised one. Declared by role anyway
(page, card, third), the derived dark ramp is monotonic, which means that
third token comes back the **lightest**: recessed and raised swap places. It
stays perfectly legible, and it is the right trade — but know that a mood
derivation will always hand you a monotonic ramp, so a token whose whole job
is to sit *behind* the card cannot keep that relationship through one.

**What each of the five actually does when a mood is derived**, measured:

| `kind` | behaviour |
|---|---|
| `surface` | a **positional ramp** — declaration order is page, card, raised |
| `onSurface` | **each token is derived individually**, against whatever background its pair names — not flattened. Two of three can still land on the same value when their pairs point the same way; that is the pair talking, not a collapse |
| `accent` | first one or two get real values, **everything after collapses onto one** |
| `status` | same collapse, and a tint lands identical to its base |
| `border` | one value, derived to sit between surface and text |

`onSurface` is the safe one: declare as many texts as you have, they are
treated individually and each is derived against whatever `contrastPairs`
says it sits on (§2.1.3). `accent` and `status` are where the catalog has to
be designed around the limit below.

##### The expensive part: derivation collapses siblings onto one value

`accent` and `status` are **not** ramps with room for everyone. Derivation fills the first one or two and then gives **every remaining token of that kind the same single value**. From a real "make it dark" turn against a catalog declaring eight accents and five statuses:

```
--brand            #4f50ff   ← kept
--brand-strong     #8c8dff   ← its paired step
--brand-tint       #bb56f8   ┐
--brand-100        #bb56f8   │
--brand-700        #bb56f8   ├── eight accent tokens,
--accent-amber     #bb56f8   │   one purple between them
--accent-teal      #bb56f8   │
--accent-teal-tint #bb56f8   ┘

--success          #39c650   ┐ tint identical to its base:
--success-tint     #39c650   ┘ a pale background is now full-strength
--warning          #c69639   ┐
--warning-tint     #c69639   ┘
```

Nothing rejects this. The contrast floor doesn't catch it either — it only covers pairs you declared. In an app where `--success-tint` is the background *behind* `--success` text, that turn produces green text on a green field, and the sentence says the theme was adapted "for better contrast".

The obvious repair — drop `kind` from the tints so derivation skips them —
**does not work, and it is worth knowing why before you try it.** Same
catalog, same *"make it dark"*, `kind` removed from every variant token:

```
                     kind declared     kind removed      factory
  --brand-tint       #bb56f8           #edeeff           #edeeff
  --success-tint     #39c650           #e3f6ec           #e3f6ec
                     (= its own base)  (never touched)

  ...and the page background it now sits on: #0f0f15
```

Skipped means *left at its light-mode value*. A near-white block on a
near-black page. One bug traded for its mirror image: with `kind` the pale
background goes full-strength and the text on it disappears; without `kind`
it stays pale while everything around it goes dark.

**The real rule is upstream of `kind`: a token whose value is a function of
another token does not belong in the catalog at all.** A tint, a hover step,
a focus ring, a lightest/darkest ramp step — compute those in your own
stylesheet from the base token (`color-mix()`, a relative colour, an alpha
layer) and declare only the base. Then there is exactly one thing for mimi to
change, the derived ones follow it automatically, and they stay correct in a
mood mimi has never seen.

```
  declare this          not these
  ────────────          ─────────
  --brand               --brand-tint    = color-mix(in oklch, var(--brand) 12%, var(--surface))
                        --brand-strong  = color-mix(in oklch, var(--brand) 80%, black)
```

**Mix toward a token, not toward black or white.** The example above mixes
`--brand-strong` toward `black`, and that only works in a light app. Measured
on an app with a hand-written dark palette, the relationship inverts: the
hover step is *darker* than the brand in light mode (`#4f50ff` → `#4849e8`)
and *lighter* in dark mode (`#8586ff` → `#a3a4ff`). One formula cannot do
both against a fixed pole. Mixing toward `var(--text)` can, because that pole
is dark on a light theme and light on a dark one — and stays correct in a
palette mimi invents, since mimi derives the text against the surface. Same
for tints: mix toward `var(--surface)`, never toward white.

**And if your app already has a `prefers-color-scheme: dark` block, that
block is where this quietly fails.** mimi writes its values as inline custom
properties on the root element, which beat every stylesheet rule — including
the media query. But a derived token *redeclared inside that media block*
still wins over the same token declared outside it, so for anyone whose OS is
dark, the variants stop following mimi's theme entirely and the tint bug is
back in full. Declare each derived token **once, outside both blocks**, and
let the media query restate only the base tokens. Then whichever set of bases
is live — light, hand-written dark, or something mimi made up thirty seconds
ago — the variants follow.

```
  :root { --brand: …; --surface: …; --text: …;          ← bases
          --brand-tint: color-mix(…var(--brand)…var(--surface)); }   ← derived, ONCE

  @media (prefers-color-scheme: dark) {
    :root { --brand: …; --surface: …; --text: …; }      ← bases only, never the derived
  }
```

Fitting the percentages is arithmetic, not taste: `color-mix(in srgb, …)`
interpolates the gamma-encoded channels directly, so you can solve for the
percentage that reproduces each literal you are replacing and check the error
per channel before you commit. On one real palette the brand ramp came out
exact (≤0.5/255 on every channel, both schemes) and the hand-picked pale
tints within ~5/255 — invisible, and now correct in a mood nobody has seen.

If your stylesheet already hard-codes those variants as literals, this is a
real refactor and you may not want it today. In that case declare `kind` and
accept the flattening: a wrong-but-visible colour is easier for someone to
notice and ask you to fix than a pale block that only breaks in dark mode.
Just do not tell yourself that leaving `kind` off has solved it.

**The flattening is a ceiling you can stay under, not a law.** The same
stylesheet, refactored so every variant is computed and only the bases are
declared, ends up with **one** `accent` and **three** `status` tokens — and a
"make it dark" turn against it flattened nothing. Measured, the whole action:

```
  --bg #0f0f15  --surface #181922  --surface-alt #1d1d28     ← the ramp
  --text #f6f6f8  --text-muted #99999e  --on-brand #f6f6f8   ← each derived to its pair
  --border #343439
  --brand      unchanged  ("using your brand color as the base")
  --success #39c650   --warning #c69639   --critical  unchanged
  --dark, --accent-amber, --accent-teal   untouched — no `kind` declared
  all four radii                          untouched — dimensions aren't derived
```

Two things worth reading off that. One accent cannot collapse onto anything,
and mimi kept it and built the theme around it. And with three statuses, the
third was simply **left alone** rather than given a sibling's colour — so the
"everything after collapses onto one" in the table above is what a long list
does, not what the third element does. Design for one accent and a short
status list and the whole problem disappears.

There is a second half to the picture, and it is more encouraging:

**The model writing values by hand is much better than the mechanical derivation.** The same catalog, asked *"make the brand colour a warm orange"*, produced a genuinely correct ramp:

```
--brand        #ea580c     --brand-tint   #fff7ed   (pale, still a background)
--brand-strong #c2410c     --brand-100    #ffedd5   (light)
--brand-700    #7c2d12     (dark)
```

Targeted requests get reasoning; whole-mood requests get the ramp algorithm. Design the `kind` coverage around that, not around "declare everything".

#### 2.1.3 · `contrastPairs`: what text reads on what background

```js
contrastPairs: [
  { text: "--ink", bg: "--paper" },
  { text: "--on-brand", bg: "--brand" },
]
```

**A pair does two jobs, and the second one is the reason to bother.** It sets the floor (4.5:1, WCAG AA) that mimi honours when it derives that text — verified: a dark-mode turn returned `--text: #f6f6f8` on `--bg: #0f0f15`, comfortably clear. But it also **decides which background that text is derived against at all**. Without a pair, every text is computed against the *first declared surface*, whatever is really behind it.

That is what makes a text on a coloured chip or badge work. In one measured turn, a token paired against a bright badge derived **dark** (`#121c18`) while body text in the same theme derived **light** (`#f6f8f8`) — opposite directions, from one request, because each was told what it sits on. Miss the pair and that badge label gets computed against the page background and comes out the same colour as the body text, unreadable on the chip.

**Important and counterintuitive:** declaring contrast protects **only what mimi derives**, and only for the pairs you actually listed. A one-off value the model writes by hand isn't checked — *"make the text light gray"* over an almost-white background gets saved as-is. Neither is a pair you didn't declare, which is exactly how the tint collapse above goes unnoticed. If your app needs that floor guaranteed, check it yourself when you receive the `action` (section 3).

#### 2.1.4 · `concepto`: the bridge to the shared brand vocabulary

This is an **optional** field and only matters if your app takes part in a brand shared across several people (section 2.3). There are exactly twelve ids, no more:

`color-principal` · `color-sobre-principal` · `fondo` · `texto` · `acento` · `accion` · `exito` · `destaque` · `forma` · `sombra` · `espaciado` · `tipografia`

An id outside that list, or two tokens pointing at the same concept, get discarded with a warning to mimi's log — they never break the turn, and your app never finds out.

#### 2.1.5 · `varKeys` and `componentProps`

`varKeys` is the save allow-list: its union with the `tokens` keys is what the model can write. A key in `varKeys` but not in `tokens` is still accepted, but with no `type` — meaning any value passes, unvalidated.

`classes` is the same idea for whole CSS classes rather than variables. If your app doesn't paint by class — most don't — leave it `[]` and forget it; it is in the example only because the shape requires the key.

`componentProps` **empty does not mean "no components": it means "don't validate props".** If your app has separately paintable components, declare the valid props there (`bg`, `color`, `radius`, `shadow`…); leave the list empty and mimi accepts any prop name without objecting.

#### The most expensive trap: a catalog with `tokens: []`

It reads, intuitively, as *"I'm declaring zero tokens"*. It's the opposite: with `tokens` empty (or missing, or with no element carrying a `key`), the whole catalog collapses to `null`, and with `null` **mimi validates absolutely nothing** — the model can invent any key with any value, your app receives an `action` full of garbage, and the sentence already told the person it applied. It's deliberate (a catalog with zero tokens that did validate would reject everything while the chat claims it applied), but it's the exact opposite of what most people expect.

#### Bump `version` on every catalog change

mimi caches the prompt and the validator per process, under a fingerprint: `appId@version` plus token count, JSON length, and `varKeys` count. A change that doesn't move those numbers — fixing a `label`, say — **doesn't invalidate the cache**: the process keeps serving the old version until it restarts. The simple rule: changed the catalog, bump `version`.

### 2.2 · `current`: how the app looks today, for this person

```js
current: {
  vars: { "--brand": "#0057ff", "--paper": "#ffffff", "--ink": "#111827" },
  scopes: { "header": { "--brand": "#003d99" } },   // optional
  components: { "cta-button": { "bg": "#0057ff" } }, // optional
  own: false,         // are these values HERS, or her organization's brand?
  hasBrand: true,     // does her organization have a defined brand?
}
```

**Sending `current` isn't just courtesy: it's what makes the `preview` that comes back self-sufficient.** If you send it, the theme in the preview event arrives **fully resolved** — verified: a catalog of 23 tokens got all 23 back, including the ones the turn didn't change. If you don't send it (or send it empty), the preview carries **only what this turn changed**, and your app has to merge it by hand with what was already there, or the rest of the screen falls back to its defaults.

**If your app has TWO factory palettes — a light one and a dark one — the baseline is double too, and your server doesn't know which one that person is looking at.** Their operating system decides it, in the browser, and the turn leaves from the server. If you always send the light one, every relative request from someone sitting in dark — "a bit softer", "raise the contrast" — is computed against colors they do not have in front of them, and the sentence will claim a change they can't see. The browser has to tell **your** server which of the two it is in, before that server builds `current`.

**`own` and `hasBrand` still have to be right when your app has no shared brand at all.** They live in this block, not in §2.3, so it is easy to skip past them along with the brand section and leave them to guesswork. With no brand concept, send `hasBrand: false` and `own: true` (these values are this person's own).

**Even then, mimi will tell your users about an organisation they don't have.** With `brand: null` and `hasBrand: false`, a reset turn answered *"I've reset your theme, so you're back to following your organization's original brand appearance"*; on a second app, same settings, *"I've reset your theme back to your organization's default appearance."* And it is not only resets — see §2.1.1 for a shadow refusal that blamed *"you don't administer the organization's brand"*. Nothing in the response marks any of those as wrong and your app cannot correct them.

The one place you can do something about it cleanly is the reset, because a reset has no nuance to lose: your app already knows exactly what happened (everything went back to the stylesheet), so **show your own copy on `reset_theme` and mimi's own sentence everywhere else.** That is a deterministic branch on the action name, not string-matching a model's prose in three languages — which is the fix to avoid.

#### Nothing tells you the page just went dark

If your stylesheet carries a `prefers-color-scheme` block, this bites and it is easy to miss. mimi's values land as inline custom properties, which beat the media query — so a person on a light OS can end up looking at a genuinely dark page. But `color-scheme` itself is a normal property that nobody overrode, so it still says `light`, and the browser goes on painting scrollbars, date pickers and autofill highlights for a light page.

There is no field in the response for this and there shouldn't be — how dark a theme is isn't mimi's to know. Derive it from the background it returned: compute the relative luminance of `--bg` (or whatever your first surface is called) and set `color-scheme` yourself. Every measured turn answered in hex, so a hex parse with a null fallback for anything else is enough.

`vars: {}` (or `current: {}`) and **not sending `current`** are two different statements to mimi: the first says *"this person is on the factory theme, there's nothing to multiply"*; the second says *"I don't know how it looks"*, and mimi doesn't invent anything about that. If you have the data, send it even if it's empty — don't skip it for tidiness.

#### Send the resolved values, not just this person's overrides

This one bites on the very first turn and looks like the model lying. Your stylesheet's own defaults are **not** part of the catalog — a catalog declares names, types and roles, never values — so if you send only what this person has changed, someone who has changed nothing sends `{}` and **mimi has no idea what your app currently looks like**. A relative request then has nothing to be relative to, and the model fills the gap by inventing a baseline.

Verified, against a catalog whose factory corner radii are `8px / 12px / 16px / 24px`:

```
current.vars = {}            "make the corners a bit more rounded"
  →  0.5rem  0.75rem  1rem  1.5rem      ← the 8/12/16/24 defaults, in another
                                          unit. Nothing changed, and the
                                          sentence said it had rounded them.

current.vars = the resolved theme        same request
  →  10px  14px  20px  28px             ← actually a bit more rounded
```

So lay your factory values underneath the person's overrides before the turn goes out: `{...factoryVars, ...personOverrides}`. Do it on the server that owns the catalog, not in the browser — anything else calling that endpoint needs the same baseline, and two copies of the rule drift.

Note this is the *opposite* merge from the one on the way back: full values go **out**, and only the delta gets stored when it comes **in**.

**Those factory values are now written down twice, and nothing checks.** They
live in your stylesheet, which is the real source of the look, and in
whatever object you send as the baseline. When someone retunes a colour in
the stylesheet, mimi keeps computing against the old one and every relative
request is quietly wrong. Parsing the stylesheet's own `--x: value;`
declarations in a test and asserting they still match is about thirty lines
and is the only thing that catches it. The same test is the natural place to
assert the other two rules that are invisible in a diff — that no declared
token's value contains `var(` (it is derived, §2.1.2) and that none contains a
space or comma (it is a shadow, §2.1.1).

Only **text** values survive in `vars`/`scopes`/`components`: a number or `null` in one of those keys is silently dropped.

### 2.3 · `brand`: only if your app has a shared brand

**This is optional and advanced.** If your app doesn't have the concept of "the organization's look, distinct from each person's" **and you don't want a per-person file either** (§0.3, question 2), don't send this block (or send `brand: null`) and skip this section entirely.

**But if you want the per-person file and have no organizations, the block still travels**: with `userId` and `canEditBrand: false`, and nothing else. A person's layer is resolved whether or not there is an organization — it is the case of an app where the only thing that exists is what each person asked for.

If you do have it:

```js
brand: {
  org: "acme-corp",              // who the organization is — without this, mimi ignores the brand entirely
  canEditBrand: false,           // the permission YOUR app already verified — mimi never decides it
  userId: "u-4471",              // who this person is
  brand: { "color-principal": "#0057ff", "fondo": "#ffffff" },  // current values, in CONCEPTS
  manual: null,                  // { personalidad, noHacer, conceptos } — intent in words, optional

  // And ONE of these two, only if you want a brand file to exist (§2.3.1).
  // They are MUTUALLY EXCLUSIVE: sending both is a caller error.
  brandId: "acme-design-brand",  // we keep the file and maintain it
  // designMd: "---\nname: …"    // or your client brings it written, and we store nothing
}
```

`canChangeOrg` is accepted as the same permission as `canEditBrand`: either one set to `true` is enough.

Three things worth knowing:

- **`canEditBrand` comes from a claim you already verified**, never from something the browser sends unchecked. Only exact `true` counts; anything else (text, number, absent) counts as `false`. With `false`, mimi treats any request as personal to that person; with `true`, as a change to the whole organization.
- **`userId` decides whether this person's request is treated as personal or as the organization's.** Without it, even with `canEditBrand: false`, mimi has no way to tell "this is an ordinary person" from "this is the whole organization" — so it treats it as the organization. If your app has the concept of a shared brand, always send `userId` for anyone who doesn't administer it.
- **The concepts that come back in `reset_theme.args.concepts` are not projected onto your tokens.** mimi sends the raw ids from the shared vocabulary (`fondo`, `color-principal`…); translating them to your own variable names, with your own contrast floor, is your job if you use this path.

#### 2.3.1 · The brand file: what it is, and who holds it

This is the part most integrations don't know exists, so what it is comes first.

**Every time someone asks for a change, mimi can write it into a `design.md`-format text file** — one per organization, and one per person. It creates it the first time and keeps it current on its own. It stores the value, and a line saying what that color is for.

**That file paints nothing.** It is not what your app reads to draw itself and it does not replace what you store: your app keeps saving and painting what the turn returns, exactly as without this. The file is the record of what was decided.

**What having it buys you.** That same file can dress a **different** application, with different variable names, without copying a single color by hand: the new app sends its own catalog and the same identifier, and gets the values in its own vocabulary. It works because the file doesn't only store values — it also stores what each one is for, and that is what makes it possible to resolve a token the file never named. One in five real documents carries no machine-readable value at all: those get resolved by reading the prose, once, when they are loaded.

The three options, and all three are valid:

```
1. YOU SEND NEITHER brandId NOR designMd
   mimi returns values and stores nothing. It is all yours. This is this
   guide's default path and nothing is missing from it.

2. YOU SEND designMd
   your client manages their file and hands it to you written on every turn.
   mimi understands it and stores not one row of yours.

3. YOU SEND brandId
   we keep the file and maintain it.
```

The three identifiers, which are not interchangeable:

| What you send | Example | What it does |
|---|---|---|
| `org` | `acme-corp` | **switches the whole brand block on**: if it doesn't arrive as a string, mimi ignores the brand entirely. It is the name it uses when speaking to the person, and the file's title if it has to create one |
| `brandId` | `acme-design-brand` | the name of the ORGANIZATION's file in our store |
| `userId` | `u-4471` | the name of THAT PERSON's file |

**The trap, and it is the only one in this section:** if you send `brandId` and do **not** send `userId`, someone who does not administer the brand gets a sentence saying their change is only theirs, while what they asked for is written into the organization's file — everyone's. Both halves come out of the same turn and neither one fails. If your app cannot say who the person is, then only people who administer the brand may reach the chat.

**What is not built yet:** taking the file out. The address that serves a brand returns the *understood* form — the values per concept and the intent in words — not the text. For a client to carry it to a vendor that isn't us, that door is missing. Say it that way when you're asked: the file exists and maintains itself; the move-out does not.

#### The save request does not say whether it is personal or organisation-wide

This is the most expensive thing on this page, because the chat has already
promised the person something your code has no way to see.

`canEditBrand` changes what mimi *says* and what it derives, but **not the
shape of what it asks you to save**. The same request, same catalog, same
fictional organisation, differing only in that flag:

```
canEditBrand: true
  sentence  "I have updated the company brand to a deep forest green
             for the entire organization."
  action    {name:"save_theme", args:{theme:{vars:{…5 brand vars…}}},
             subject:"theme"}

canEditBrand: false
  sentence  "I've updated your personal brand colors… since you don't
             administer the organization's brand, this change applies only
             to your own appearance, and you will stop receiving brand
             updates from <org> until you reset your theme."
  action    {name:"save_theme", args:{theme:{vars:{…18 vars…}}},
             subject:"theme"}
```

Two different promises. **One identical instruction.** There is no field —
not `subject`, not a scope, not anything — that tells them apart.

So never try to read the scope out of the answer. **Route the write by the
permission you already verified before the turn went out**: your app is the
one that decided `canEditBrand`, and that same decision is what picks the
place to write. An app that stores every `save_theme` in the same row will
file an organisation-wide rebrand as one person's private theme, while the
chat has told them the whole company changed — and nothing anywhere reports
a failure.

Two smaller things that fall out of the same comparison: as an administrator
mimi scoped its answer to the brand tokens alone, while for an ordinary
person it derived a whole personal theme — so do not assume a fixed set of
keys. And the sentence for an ordinary person correctly warns that a
personal theme **stops that person receiving future brand updates** until
they reset. If your product has that behaviour, your storage has to actually
implement it; if it doesn't, mimi is telling your users something untrue and
you need to say so in your own copy.

---

## 3 · What it hands back

### The envelope, exactly

This is where an implementation written from intuition fails, so it is worth being literal. Every SSE frame is `data: ` plus one JSON object. Inside, under `result`, there is **exactly one key, and that key is the event type**:

```json
data: {"result":{"statusUpdate":{"taskId":"…","status":{"state":"TASK_STATE_WORKING"}}}}

data: {"result":{"artifactUpdate":{"taskId":"…","artifact":{"name":"theme-preview","parts":[{"data":{"vars":{…}}}]}}}}
```

So the parse is: take `result`, read its single key to learn the type, and the value is the payload.

```js
const outer   = frame.result ?? frame;
const evtType = Object.keys(outer)[0];      // "task" | "statusUpdate" | "artifactUpdate"
const payload = outer[evtType];
```

**The two artifacts are told apart by `artifact.name`:**

| what it is | `artifact.name` | payload lives at |
|---|---|---|
| the theme to paint live | `theme-preview` | `artifact.parts[].data.vars` |
| the save request | `proposed-action` | `artifact.parts[].data` |

There **is** a `kind` field, and it is not the one you're thinking of. It sits
on `artifact.metadata`, not on the parts — measured, verbatim:

```
"artifact":{"name":"theme-preview",   "parts":[…], "metadata":{"kind":"preview"}}
"artifact":{"name":"proposed-action", "parts":[…], "metadata":{"kind":"action","action":"save_theme"}}
```

Read `artifact.name` anyway: it is the field this whole guide is written
around, and `metadata` is the sort of thing that grows an entry one day. The
point of mentioning it is that seeing a `kind` in `SendStreamingMessage` is
**not** evidence you are on the other protocol — an earlier version of this
file said there was no `kind` anywhere and sent a reader hunting for a
protocol bug that wasn't there.

### The order is not a contract

A real turn, in the order it actually arrived:

```
1. task                                     started
2. statusUpdate  TASK_STATE_WORKING         opening, no text
3. artifactUpdate  theme-preview            ← the preview, BEFORE any sentence
4. statusUpdate  TASK_STATE_WORKING         the sentence
5. statusUpdate  TASK_STATE_WORKING         metadata {tool, toolStatus} — progress, not confirmation
6. artifactUpdate  proposed-action          the save request
7. statusUpdate  TASK_STATE_COMPLETED
```

**The preview normally arrives before the sentence, not after it** — and the interleaving varies between turns: the same catalog produced 7 events on one turn and 8 on another, with an extra `WORKING` before the preview. Only 1, 2 and the terminal state are guaranteed. Handle each event by its type as it arrives; never key behaviour off position or count. A turn that only answers a question, or whose request the catalog rejected, ends `COMPLETED` without ever emitting a preview or an action — and **that is not an error**.

### The text

It arrives inside `statusUpdate.status.message.parts[].text`, **in pieces, and you must concatenate them**. A one-line answer often does arrive whole, which is exactly the trap: anything longer does not. Measured on real turns — 3 events for a short refusal, 9 for an answer about what it can do, 30 over 2.5s for "list everything you can and can't change". Render the accumulated string, never the latest event on its own, or a long reply will flicker through fragments and settle on the last few words. You can also get a `COMPLETED` without a single word of text having arrived — show that normally, not as a failure.

**It is Markdown, and it will quote your own variable names back at the
reader.** Nothing in the response says so and there is no plain-text
alternative. A real answer, first lines verbatim:

```
### 🎨 What I CAN Change
I can customize the look and feel of the app by adjusting the following design tokens:

*   **Colors & Surfaces:**
    *   **Page Background (`--bg`):** The main canvas background.
```

Headings, bullets, bold, emoji, and `--bg` — a name your stylesheet chose,
now on screen in front of a person who has never heard of it. If your chat
surface renders Markdown, fine. If it is plain text — a toast, a one-line
bar, a native alert — those markers show up as literal asterisks and hashes,
so flatten them on the way through. Flattening formatting is presentation and
is fine; rewording the sentence is not, because it is the only account of
what actually happened.

**Whether the stream is really a stream depends on the turn.** Measured: a
turn that *changes the theme* delivered every frame in the same millisecond —
preview, sentence, action and `COMPLETED` all at +2183ms on one turn and
+1996ms on another, after a two-second silence. Only a conversational answer
actually trickles. So if your surface isn't a live-typing transcript, buffering
the whole turn on your server and answering with one JSON object costs nothing
and removes the entire "paint a preview, then maybe discard it" problem below.
Both shapes are legitimate; the guide's own suggested split (normalise each
event and forward it on) is the right one only when something on screen
benefits from seeing the preview two seconds early.

### The preview (`artifact.name === "theme-preview"`)

```json
{ "vars": { "--brand": "#16a34a", "--paper": "#fbfcfb", "--ink": "#121c16" } }
```

This is what to paint while the turn is seen happening. **If you sent `current`, it arrives fully resolved**; if not, it arrives bare (only the delta) and you have to merge it yourself.

It doesn't always arrive: an empty request, a patch the catalog rejected, a concepts-only turn, or a `reset` with a brand to return to (that case explains itself — it avoids the flash of the factory theme) don't emit a preview.

**If the turn ends in `FAILED` after a preview and with no `action`, discard what you painted** and go back to what was there: nobody asked for it to stay that way. The same applies to a `COMPLETED` that carried a preview but no action.

Discarding has one trap that fails silently. Inline custom properties are not
enumerable the way you expect: `Object.keys(el.style)` yields `"0"`, `"1"`,
`"2"` — indices, not names — so the obvious loop clears nothing and the
rejected preview stays on screen looking applied. Reach them through
`el.style.item(i)`, or simply re-apply the values you saved before the turn
started. Save those first; you cannot reconstruct them afterwards.

### The save request (`artifact.name === "proposed-action"`)

There are exactly two, and **you execute both** — mimi never writes anywhere of yours. The real shape, verbatim:

> **Which half of your app executes it?** The stream only ever reaches your
> server, so the server is what *sees* the action — but the paint has to
> happen in the browser, and the two are not the same instruction. Simplest
> split that stays correct: the server normalises each event into something
> small (`text`, `preview`, `action`, `done`) and forwards it on; the browser
> paints previews, merges the delta and hands it to whatever your persistence
> is. Wherever you draw that line, the advice below about *merging before
> painting* and *not saving the preview* belongs to whichever side is holding
> the values — say so in a comment, because the next reader will assume the
> other one.

**`save_theme`**
```json
{
  "name": "save_theme",
  "args": { "theme": { "vars": { "--radius-sm": "16px", "--radius-lg": "32px" } } },
  "subject": "theme"
}
```
`args.theme.vars` is the **delta**, not the resolved theme — merge it against what that person already had, never replace the whole thing. (Verified: a turn that changed only shape returned only the four radius keys, out of a 23-token catalog.) If `args.concepts` also comes along, those are concepts your catalog can't paint today; store them wherever you keep the brand, without trying to apply them.

**`reset_theme`**
```json
{ "name": "reset_theme", "args": {} }
```
or, if there's an organization brand to return to:
```json
{ "name": "reset_theme", "args": { "concepts": { "color-principal": "#dc2626", "fondo": "#0f1513" } } }
```
`args: {}` means *"go back to the factory theme"*. With `concepts`, it means *"go back to your organization's brand"* — treating them the same sends someone to the default when they asked to go back to their brand.

**Storing the delta is not enough: the way back is missing, and it is yours.** The browser does not go through a turn to paint itself on page load, so your app needs an address of its own that returns what that person has stored, and has to apply it before painting. Without it the feature lasts **exactly one turn**: someone changes the color, reloads, and the factory one is back, with nothing failing and nobody filing it as a bug. Return the stored delta, not the resolved theme, for the same reason you don't store the preview.

**The `subject` field** always travels in the action object, alongside `name` and `args` (never inside them) — its observed value is `"theme"`. It's internal routing for another system; your app must **ignore it entirely** and never store or forward it.

A turn can close `COMPLETED` with no `action` at all — there was nothing to save. And the cutoff for running out of steps (if the model gets stuck in a loop) also closes `COMPLETED`, with an apology in English, still asking that whatever was already previewed be saved: `COMPLETED` is not a synonym for "went perfectly".

### When it fails

The turn closes `TASK_STATE_FAILED` with a generic English text (*"Something went wrong on my side. Please try again."*) or, if the error escaped the code, with the exception's raw message. Neither is meant to be shown as-is — show your own copy, and if you need to diagnose, there's no error-code field: only the text. Put that text in your log, clipped and with newlines stripped, so a stray line break can't forge log lines.

### What you won't receive

There's no visual card, no button, no confirmation step. mimi answers with **theme + sentence**, nothing else — there is no "waiting for you to confirm" state. What got previewed is, unless the turn fails, what gets asked to be saved when it closes.

---

## 4 · What it understands, and what it doesn't

- **Can**: colors, corner radius, borders, spacing and typography — as long as your catalog declares them as tokens with a `type` that validates (see 2.1.1 on why shadows are the awkward one).
- **Can never touch, and will say so in a sentence instead of redirecting anyone**: system typography (fonts, not the typography token if you declare one), your app's logo or images, layout (position, order, adding or removing elements), *global* sizes and spacing for the product, and the application's behavior.
- **The contrast floor (4.5:1)** is only enforced when mimi derives a full palette from a seed, and only for pairs you declared — never on a one-off value.
- **A brand concept your catalog can't paint** still gets saved (in `args.concepts`) and reported as such — it's never claimed to have been applied.

---

## 5 · Real turns, verified

Against a 23-token catalog (three surfaces, two texts, eight accents, five statuses, one border, four radii — no shadows declared):

> **"give it much rounder corners and softer shadows"**
>
> **Sentence:** *"I've made all the corners much rounder. Please note that customizing shadows is not supported at the moment."*
>
> **`action`:** `{"name":"save_theme","args":{"theme":{"vars":{"--radius-sm":"16px","--radius-md":"24px","--radius-lg":"32px","--radius-xl":"48px"}}},"subject":"theme"}`

It changed what it could and **said in words** what it couldn't — it never claimed to have touched a shadow. The save request is a clean delta: four keys, nothing else.

> **"make the brand colour a warm orange"**
>
> **`action` vars:** `--brand: #ea580c` · `--brand-strong: #c2410c` · `--brand-700: #7c2d12` · `--brand-100: #ffedd5` · `--brand-tint: #fff7ed`

A targeted request gets a properly reasoned ramp — dark step dark, pale step pale. Compare that with the mood derivation in 2.1.2, which flattened those same five tokens onto one colour. **The difference is not the catalog; it's whether the model chose the values or the ramp algorithm did.**

---

## 6 · Failures that give no error at all

- **Calling `message/stream` because it's A2A's standard name.** Same URL, answers 200, and every shape in §3 is different — including a `kind` field on everything. Send `SendStreamingMessage`.
- **Reading the artifact by a `kind` field on the part.** There isn't one there: it's `artifact.name`, and the values are `theme-preview` and `proposed-action`. (`artifact.metadata.kind` does exist and does carry `preview`/`action` — see §3. Seeing it is not a sign you're on the other protocol.)
- **Rendering the latest text event instead of the accumulated string.** Short replies arrive whole, long ones in up to nine pieces; the bug only shows on the long ones.
- **Assuming the app has somewhere to run a server.** A static site doesn't, and there is no browser-side alternative — building one is most of the work.
- **Building the classifier without ever asking whether the person could just say who they mean.** §0.2's tree only asks what your backend talks to, so it cannot see the gesture your composer may already have. The classifier then works, demos fine, and quietly becomes a boundary you maintain forever — for turns that never needed it.
- **Trusting that a chat already on the page is a chat you can route.** Send its backend one real request before you build on it.
- **Clearing a rejected preview with `Object.keys(el.style)`.** That yields indices, not property names, so nothing gets cleared and the preview stays.
- **Parsing the event as `result.status` / `result.artifact`.** The event type is the *single key under `result`*; the payload is its value.
- **Assuming the preview comes after the sentence.** It normally comes first, and the interleaving changes between turns.
- **Calling mimi from the browser.** Dies in the preflight, with no log anywhere, because there isn't a single CORS header.
- **Missing `messageId`.** HTTP 200 with the error inside the body — check the JSON, not the status.
- **`app_id`/`context` in `message.metadata` instead of `params.metadata`.** The turn answers 200, mimi answers blind.
- **Omitting `contextId`.** Every request is a new conversation; follow-ups ("now lighter") have nothing to compute against.
- **A catalog with `tokens: []`.** Reads as "zero tokens declared" and actually turns off all validation.
- **Not declaring `kind` on any token.** Deriving a full mood falls back to names that aren't yours and paints halfway.
- **Putting a token in the catalog whose value is a function of another one** (a tint, a hover step, a focus ring, a ramp step). Declare `kind` on it and mood derivation flattens it onto its siblings — a pale background turns full-strength. Leave `kind` off and derivation skips it — the same pale background stays light-mode white on a dark page. Both were measured; there is no third setting. Compute those in your stylesheet from the base token and declare only the base.
- **Declaring a shadow token.** No `type` validates it, so any string the model writes lands in your CSS.
- **Trusting that declaring `contrastPairs` protects any change.** It only protects derived values, and only for the pairs you listed.
- **Sending only this person's overrides as `current`.** Someone who has changed nothing sends `{}`, so a relative request ("a bit more rounded", "slightly darker") has no baseline and the model invents one — then says it applied a change it didn't. Send the resolved theme: your factory values with their overrides on top.
- **Saving the theme that comes in the `preview`.** It's the resolved one (if you sent `current`); saving it as-is also persists what nobody asked to change.
- **Painting the theme that comes in the `action` without merging it.** It's the delta; painted alone, the rest of the screen falls back to your app's defaults.
- **Collapsing "nothing to paint" into "undo".** A turn that only answers a question, or whose request mimi turns down in words, closes fine and emits neither preview nor `action`. If you collect the turn into one response of your own and that response says the same thing — "no values" — for that case and for a reset, whoever paints reads it as a reset and **wipes the theme the person already had on**: they ask for dark mode, get it, ask *"what else can you change?"*, and the page snaps back to factory white while they read the answer. Nothing fails — the text renders normally, what you stored is still there, and a reload brings it back — so it gets reported as "sometimes my theme disappears" and never reproduces. There are THREE situations (something changed · undo · touch nothing) and they need three signals, not one field that may come back empty. Test the third one: it is the one nobody thinks to test.
- **Forwarding or storing `subject`.** It belongs to another system; ignore it.
- **Looking for the scope of a brand change in the response.** A personal theme and an organisation-wide rebrand come back as the same `save_theme`, with only the sentence differing. Decide where to write from the permission you verified on the way in.
- **Expecting a visual card or a confirmation step.** Neither exists — the contract is theme + sentence, and what gets previewed is what gets saved.
- **Rendering mimi's sentence as plain text.** It is Markdown, with emoji, and it quotes your own variable names back at the reader. In a toast or a one-line bar those markers show up literally.
- **Passing a browser-chosen `contextId` straight through.** Conversations live in mimi's process memory keyed by that string, with no credential in front of them — two people on one string share a conversation. Namespace it per user on your server.
- **Letting mimi's own words explain a reset when your app has no organisations.** It will say the person is back on their organisation's brand. Branch on the action name and use your own copy for that one case.
- **Assuming `color-scheme` follows the theme.** It doesn't: mimi's inline values beat your `prefers-color-scheme` block, but the declared `color-scheme` isn't one of them, so native controls stay light on a dark page. Derive it from the background it returned.
- **Redeclaring a derived token inside a `prefers-color-scheme` block.** It then wins over the one declared outside, so for anyone on a dark OS the variants stop following mimi's theme — the exact bug computing them was supposed to fix, visible only to half your users.
- **Writing the factory baseline down a second time with nothing checking it.** The stylesheet moves, the baseline doesn't, and every relative request is computed against colours the app stopped using.
- **Trusting a comma-separated "not for…" clause to hold the negative half of the boundary.** Measured, layout still routed to appearance until the exclusions became their own list with real phrasings.

---

## Feeding this back

**This file is the deliverable, not the integration you just built.** Every
app it meets is a test of it, and the ones it has not met yet are where it is
still wrong. So finish the job by writing back.

While you work, keep a running note of five things, with the evidence:

1. **The road** — the shape §0 sent you to, and whether a better one existed.
   This is the one the other four cannot catch: every one of them assumes the
   route was right and only a detail was wrong. The test is what you thought
   when you finished, not what broke while you worked — *"this would have been
   simpler as X"* is the entry, and so is *"the thing I expected was never
   offered to me"*. It outranks a protocol correction, because a wrong detail
   costs the next reader an afternoon and a wrong road costs them the shape of
   their app. And nobody else can report it: by the time this file is edited,
   whoever is editing it has already taken the road.
2. **Wrong** — something stated here that the service contradicted. Quote what
   you sent and what came back: a confident false statement costs the next
   reader an afternoon.
3. **Missing** — a decision you had to make that this file does not mention.
   The test for this one is honest and simple: *did I have to work something
   out that the guide could have told me?*
4. **Ambiguous** — you picked one reading of a sentence and it could as easily
   have meant the other. Say which two.
5. **Held up** — what you verified that was already correct. Without this the
   file only ever accumulates warnings and nobody knows which parts are solid.

Then edit this file, and send the edit back. Four rules for that, all learned
the hard way:

- **Write only what you measured.** A rule inferred from one failure, written
  as though it were verified, is the most expensive kind of entry here: it
  reads exactly like the tested ones. If you have a hypothesis and no turn to
  back it, say it is a hypothesis.
- **A fix can be worse than the bug.** This file once told readers to drop
  `kind` from tint tokens; that advice was sound reasoning from a real failure
  and it was wrong (§2.1.2). Before you write "so do X instead", run the turn
  that shows X is better.
- **A fact you add makes older sentences false somewhere else.** After the
  two-protocol section went in, a line three chapters away still said there was
  no `kind` field anywhere — true when it was written, false now, and it sent a
  reader hunting a protocol bug that did not exist. The sentence that blocks
  the next person is almost never inside the passage you just edited. When you
  add something, search the whole file for every claim it just falsified, and
  re-read from the top rather than reviewing your own diff.
- **The copy in your repo is a snapshot, and an edit that stays there dies
  with it.** This file is published from one place and in more than one
  language; what you installed is a download of it. A correction kept in your
  own project improves nothing for the next reader and is one deleted branch
  from gone — and a correction made in one language leaves the others still
  saying the old thing. Send it back to where you downloaded the file, or say
  out loud that you decided not to.

If your situation was a shape §0.2 does not have a branch for, add the branch —
and if the person in your app says who they are talking to in a way §0.1 does
not name, add that too. Every case in that section grew from an app that did
not fit.

---

## For the person: how to use this

**1. Save the file** inside your project:

```
.claude/skills/mimi-onboarding/SKILL.md
```

If the folder doesn't exist:

```
mkdir -p .claude/skills/mimi-onboarding
```

**2. Save your work before starting:**

```
git status
```

**3. Open Claude Code in your project folder and write:**

```
Connect my app to mimi.
```

### What is going to happen

1. It decides, and tells you before building anything, **where the appearance
   conversation is going to live** and **how someone says a sentence is for
   mimi** — by choosing her from the box they already type in, or by having
   something read the sentence and decide, or both. If there is a chat
   already, the conversation goes in there — it will not add a second box
   beside it. Both answers are yours to overrule, and this is the moment to
   do it: everything after here is built on them.
2. It asks you **two more questions**, and waits for those answers too: whether
   you want us to keep a file with each company's brand decisions, and another
   one per person. Both are answered with what your app **can** send, not with
   what sounds good, and if you don't answer it builds without them.
3. It checks whether your app has a single place for its appearance values
   (CSS variables). If not, it builds that first — without changing how the
   app looks.
4. It writes the **catalog**: the list of what your app knows how to paint,
   with your own names. It shows it to you and waits for your green light
   before saving it.
5. It writes the code that calls mimi with that catalog and the current state,
   from your server — never from the browser.
6. It writes the code that receives the response: paints the preview, and when
   the save request arrives, leaves you the exact spot to plug in **your own**
   saving logic — without deciding it for you.
7. It fires one real turn to prove the whole path works, and shows you what
   came back.
8. It hands you a summary of what got connected.

Saving the THEME your app paints is your decision: where, in what shape, for whom. This guide doesn't make that call for you. The only thing we can hold is the file with the brand decisions, which replaces none of that — and it is decided in the two questions at the start.
