Protocol mila/v0 · unstable, pre-1.0

Build on MILA

You ship an agent definition and an MCP server. Nothing runs on the user's device and nothing runs inside MILA. This is everything that means for you.

Build on MILA

This is the developer documentation for MILA. It describes the protocol a third party implements, and nothing about how the platform is built internally.

#DocumentRead it when
00What you buildYou want the model: what you ship, what MILA owns, what you can and cannot reach.
10The manifestYou are writing the one JSON file that is your program.
20Your MCP serverYou are implementing the service MILA calls, and the auth it arrives with.
30Talking to peopleYou want to know what your program can put on screen and when it may speak first.
40ComponentsYou are choosing what to render. Every component, with its payload.
50ShippingYour program works and you want it in front of users: claiming an id, submitting a version, review, and what an update does and does not reach.

The normative contract is the protocol specification and manifest.schema.json, supplied with your developer access. Where this guide and the schema disagree, the schema wins and this guide is a bug.

Status

Protocol mila/v0 is unstable and pre-1.0. Breaking changes may land without a compatibility path. Do not build a production integration against v0 without expecting churn. From v1.0, additive fields are minor versions; removing or retyping a field is a new major, and the protocol identifier changes with it.

Conventions

  • MUST / MUST NOT / SHOULD carry their usual force. Anything else is guidance.
  • Every code sample is complete enough to run once you substitute your own domain.

00 · What you build

The one-sentence model

MILA is the host. You ship a description of an agent and an HTTPS service it can call — nothing that runs on the user's device, and nothing that runs inside MILA.

 user's device                  MILA                            you
┌──────────────┐      ┌──────────────────────────┐     ┌──────────────────────┐
│ the app      │      │ the agent loop           │     │ MCP server (HTTPS)   │
│  ─ chat      │◀────▶│ conversations + history  │◀───▶│  your tools, your DB │
│  ─ your page │ HTTP │ identity + consent       │ JWT │                      │
│    rendered  │      │ renders your components  │     │ background worker    │
└──────────────┘      └──────────────────────────┘     └──────────────────────┘

Three consequences follow, and they explain nearly every rule in this guide.

You cannot render. Your agent calls UI tools; MILA decides what the user sees. There is no styling, layout, or branding field in any schema — not restricted, absent. What you get instead is a component vocabulary that already looks native on every surface MILA runs on, including ones that did not exist when you shipped.

You cannot identify the user. You see a pairwise pseudonymous sub, different for every program. No email, no phone, no device token, ever. Two programs cannot compare notes to work out they have the same user.

You own your domain data. MILA stores the conversation and the fact that someone installed you. It does not store your objects — your MCP server does, keyed by that sub. There is no storage API to ask for and no field in which to request one.

What you hand over

#WhatNotes
1The manifest, one JSON fileIdentity, agent prompt, tool bindings, budgets, consent scopes, pages. Nothing is configured outside it.
2A reachable MCP server over HTTPSValidates MILA's token, authorises each tool on scope, keys all data by sub.
3The audience string it expectsNormally its own URL. Tokens are minted for it and rejected everywhere else.

There is no item 4. No code, no container, no image is deployed on MILA's side. You run your service; MILA stores a manifest. Adding your program does not redeploy the platform.

A background worker is a fourth thing only if your program ever speaks first — see Talking to people.

Is your idea a program?

A program fits when all of these hold:

  • the user's goal is reached by talking, not by navigating;
  • the useful state is small, personal and long-lived — a profile, a plan, a subscription;
  • you have real domain logic or data that a language model should not invent;
  • the output can be said with the component vocabulary: text, card, list, checklist, stat, media, choice, confirm, form.

It fits badly when the product is its interface (a drawing tool, a map, a game), when it needs real-time streams, or when it needs the user's account with a third party — v0 has no account passthrough, and no workaround exists.

One conversation, many programs

The user does not open your program. They have a single ongoing conversation with MILA, and everything they have installed is reachable inside it. MILA's own agent — the orchestrator — holds that conversation and delegates to your program when your program is the right one to answer.

user ─── one conversation ───► MILA
                                 ├── your program    ─► your tools ─► your database
                                 └── another program ─► its tools  ─► its database

What follows from this, and it surprises most authors:

  • You do not own a conversation. You own answers. The user never switches context to "be in" your app.
  • You cannot see the conversation. You are handed the request plus what you need to answer it. This is a privacy boundary as much as a design one: you learn only what your job requires.
  • Your display_name and description are routing inputs, not marketing copy. They are how the orchestrator decides whether to call you at all. "Plans your week of meals around your goal, your tastes and your allergies" gets routed correctly. "Eat better, feel better" does not get called.
  • Budgets nest. Yours cannot be spent by anyone else, and you cannot spend anyone else's.
  • Asking the user something ends the turn. Your question is shown by the platform; the answer arrives as the user's next message. Nothing is held in memory mid-question.

What is reserved and what is forbidden

v0note
program manifestallowedthe schema is normative
agent definition: prompt, budget, toolsallowed
nested agent as a toolalloweddepth ≤ 3, budget required at every level
MCP servers over HTTPSallowedhttp transport only
UI toolsalloweda fixed set of seven
proactive intentallowedrequires the proactive consent scope
consent scopesallowedgranted at install
budgetsrequiredevery agent and nested agent
third-party account passthroughreservedno program touches a user's external account in v0
your own UI componentsreservedthe namespace is held; not designed
open network MCP discoveryforbiddenthe reviewed store is discovery in v0
your code on the device or inside MILAforbiddenno escape hatch exists
styling, layout, brandingforbiddenno such field exists in any schema
local MCP transportsforbiddenproviders are remote services

"Reserved" means the extension point is designed so that adding it later is additive, not breaking.

10 · The manifest

One JSON file is your entire program. Normative schema: manifest.schema.json.

{
  "protocol": "mila/v0",
  "program": "meal-planner",
  "version": "0.1.0",
  "publisher": "yourco",
  "display_name": "Meal Planner",
  "description": "Plans your week of meals and the shopping list that goes with it.",
  "max_depth": 2,
  "budget": { "max_tokens": 60000, "max_tool_calls": 30 },
  "consent_scopes": ["profile.write", "plan.write", "proactive"],
  "mcp_servers": {
    "meals": {
      "transport": "http",
      "url": "https://mcp.meals.example.com",
      "audience": "https://mcp.meals.example.com"
    }
  },
  "proactive": { "enabled": true, "max_per_day": 1 },
  "agent": {
    "prompt": "…",
    "budget": { "max_tokens": 60000, "max_tool_calls": 30 },
    "tools": [
      { "type": "mcp", "server": "meals", "name": "get_profile" },
      { "type": "mcp", "server": "meals", "name": "save_profile", "writes": true },
      { "type": "ui",  "name": "ui.ask_form" }
    ]
  }
}

Required: protocol, program, publisher, agent.

version is not yours to set. Include it or leave it out; the store replaces it with the instant your submission arrived. See Shipping.

additionalProperties is false everywhere. An unknown field is a rejection, not a warning. This is what makes "providers ship no code" enforceable rather than aspirational: there is no field in which to smuggle any.

Beyond the schema, these are enforced:

  • every mcp tool MUST reference a declared mcp_servers entry;
  • nesting MUST NOT exceed max_depth (default 1, ceiling 3), checked before any agent is built, so an over-deep manifest never becomes a runnable object;
  • nested agent names MUST be unique;
  • proactive.enabled MUST be accompanied by the proactive consent scope.

Declaring what changes something

A tool that stores, updates or deletes anything MUST declare "writes": true.

{ "type": "mcp", "server": "meals", "name": "log_eaten", "writes": true }

This is not bookkeeping. MILA tells the orchestrator whether your program actually stored anything this turn, and that is what stops the platform saying "saved!" when nothing was written. Reading a profile and showing a form is not storing; only a declared writer is.

If you omit it on a tool that writes, your saves get reported to the user as though nothing happened. If you set it on a tool that only reads, the user gets told their data changed when it did not. Both are worse than they sound, because the person cannot see the difference and has no reason to check.

The five decisions, in order

Make these before writing any JSON. Each closes off the others' worst mistakes.

  1. What does the agent decide, and what does your server decide? The model is good at eliciting preferences, composing, explaining and adapting. Your server is good at facts, arithmetic, persistence, and anything a user would be angry to see invented. Put generation in the agent. Put truth in tools.
  2. What is the durable state, and what shape is it? Write the JSON of the stored object now. It becomes your tool signatures.
  3. Which consent scopes does the user grant at install? One scope per capability a user would recognise as a decision. Not one per tool.
  4. Does the program ever speak first? If yes, you need the proactive scope and a worker.
  5. What is the budget? Tokens and tool calls, per agent level. Required, not advisory.

Writing the agent prompt

The prompt is your product logic. 8000 characters, hard limit.

  • State the tool discipline explicitly. Models under-call tools. Say "Before answering anything about the user's plan, call get_plan. Never recall a plan from memory."
  • Name the UI tool for each situation. "To collect the profile, call ui.ask_form once — do not ask the fields one at a time in prose." Without this you get an interrogation.
  • Bound the elicitation. "Ask at most three questions before producing a first draft." An agent that can ask questions will ask forever.
  • Forbid invented facts in the domain your server owns. "Nutrition numbers come only from nutrition_lookup."
  • Do not gate everything behind onboarding. Decide which jobs genuinely need a profile. Sending someone to a ten-field form when they said "I ate porridge, write it down" loses the thing they asked you to keep.
  • Say what you did, from what you did. Report only work your tools actually performed.

Nested agents

A nested agent is a tool that has its own tools, invoked as a scoped sub-loop that returns one result. Use one when a sub-job has a different tool set and a different failure mode — aggregation, arithmetic, research over a long list. Do not use one to "organise" a prompt: each level costs a full model call and its own budget.

Budgets

Required at every level, and enforced. When a budget is exhausted the turn stops and the agent is told; it is not a warning.

Size them against a real transcript rather than a guess. A useful rule: whatever a task costs in the happy path, the failing path costs more, because a model that cannot finish retries. If a single operation composes a large object, have one tool accept the whole thing rather than one call per element — a week of meals is one call, not twenty-one.

The model

You may not choose the model your program runs on. MILA runs programs within its own cost policy, and a manifest naming a model outside that policy falls back to the platform default rather than being rejected. Write prompts that do not depend on the quirks of one model.

20 · Your MCP server

Your server is the database, the domain logic, and the only place your data lives.

The token that arrives

MILA calls your MCP server with Authorization: Bearer <JWT>.

claimmeaning
issMILA's issuer
audyour MCP server — the audience you declared
subpairwise pseudonymous user id, different per program
program_idthe calling program
scopethe scopes the user consented to at install
expshort-lived

You MUST:

  • validate the signature via JWKS, not a shared secret;
  • check that your own audience is present in aud — present in, not equal to. The issuer may include additional audiences, and an equality check will start failing on a change that is not supposed to be breaking;
  • authorise every tool on scope. The manifest says what you may be asked for; scope says what this user agreed to. They are not the same check.

Two properties this buys: a token issued for one provider is rejected by another, and two providers cannot correlate the same person.

Keying data

Key every row by sub. It is the only identifier you get and the only one you need.

sub is pairwise: the same human installing two programs is two unrelated ids. You cannot join across programs, and neither can anyone else. Do not attempt to reconstruct identity from what the user tells your agent in conversation — collecting it is a decision you are making on their behalf.

What you store, and what MILA stores

MILA storesYou store
conversations, messagesevery domain object you have
which programs a user installed, and the scopes granteduser profiles, plans, preferences, history
the proactive queueyour catalogues and computed results
which pages you declare, and which tool backs eachwhat those pages actually show
anything you would be sad to lose

There is no key-value store, no "program data" table, and no save() tool. The division is not a convenience: it is the reason MILA can host a provider it does not trust, and the reason you can hold data MILA never sees.

Writing tools an agent can actually use

  • The description is the API. It is read by a model deciding whether to call you. State what the tool does, when to use it, and what it returns.
  • Be permissive about input, strict about truth. A rejected call is a dead end for the user; a defaulted one still works. Reserve refusal for things that would be wrong to store.
  • Return what changed, not just ok. The agent has to tell the user what happened, and it can only report what you told it.
  • Match loosely on human input. People write "гречку" when your list says "гречка". If your tool compares strings exactly, it will tell the user something is not on a list they are looking at.
  • Never trust your own data back. Your tool results flow into a model's prompt. Treat anything a user ever gave you as untrusted when it comes back out.

Errors

Rejections are typed and returned to the agent, which can usually recover: UnknownComponent,

InvalidPayload, UnsupportedComponentVersion, UndeclaredTool, DepthExceeded, BudgetExceeded,

TurnCapExceeded, InteractionAbandoned, InteractionSuperseded.

A rejection is never rendered to the user as an error screen. Your own failures should behave the same way: return something the agent can read and act on.

Uninstall

You are not currently notified when a user removes your program. Age your data out on your own schedule, and make sure "no activity for N months" is a state your storage handles.

30 · Talking to people

Your program reaches a person three ways: inside the conversation, on its own page, and — if it has earned the scope — by speaking first.

In the conversation

Your agent calls UI tools; MILA renders them. There are seven, and the set is fixed.

toolkindreturns
ui.show_textpresentationalimmediately
ui.show_cardpresentationalimmediately
ui.show_listpresentationalimmediately
ui.show_mediapresentationalimmediately
ui.pick_from_listinteractivethe user's choice
ui.ask_forminteractivethe field values
ui.confirminteractiveyes or no

Two rules that shape how you write a turn:

  • Caps per turn: three UI calls, one interactive call. Plan the turn, do not narrate it.
  • A UI tool absent from your manifest is absent from your agent's tool list. Enforcement is by construction, not by a runtime check you might catch in testing.

An interactive tool ends the turn. Your question is shown, the run is suspended, and the answer arrives as the user's next message. Do not write a prompt that assumes it can ask and then continue in the same breath.

The component payloads are in Components.

Your own page

A program can declare pages. A page is a screen the user can open at any time, showing what your program knows — drawn with the same components, answered live by your tools when the page is opened.

Use the conversation for deciding, and the page for looking. Do not read a week's plan aloud in chat when the page carries it; show a summary and say where the detail lives. Anything a tool returns as components is drawn automatically, so returning them is how your data reaches the screen.

A page whose backing tool is slow is a page the user waits for. Answer from stored state, not from a computation you could have done at write time.

Speaking first

A program with the proactive scope may reach a user through a background worker of yours.

You do not deliver a message. You deliver an intent, and MILA runs your agent to compose what the person actually sees. The rules are normative:

  • the user is addressed only by the pairwise sub — never email, phone or device token;
  • the intent is accepted only if that user has your program installed and granted proactive;
  • it is rate-limited per program (proactive.max_per_day), and a per-program mute suppresses it entirely;
  • you can never write text or UI directly into a conversation.

The mute is the part worth designing for. A person who mutes you is not coming back, so the question for every intent is not "may I send this" but "is this worth spending the only attention I get".

Language

Speak the language the user writes in. Your prompt being in English says nothing about the person. Store what they tell you exactly as they wrote it — allergies, dislikes, titles, any free text — in their own words, never translated, never dropped for not being English. Rewriting a user's words into another language is a bug, not a tidy-up.

Photographs

If your program handles images, declare it. Images reach only programs that declared they accept them, so a photo attached in a conversation does not land in an unrelated program that happens to be installed.

Images arrive resized. Design for "good enough to identify", not for archival quality, and say when you cannot tell what something is rather than inventing a plausible answer.

UI kit — mila.ui v1

Generated from protocol/ui-catalog.json. Do not edit by hand: edit the catalog and regenerate, or the schema and the description drift apart.

A program may render only these components. Nesting is capped at 1, with at most 3 components per turn and 1 interactive.

Action ids beginning mila: are the platform's and are rejected: they route to platform controls such as installing a program.

text@1

A sentence or short paragraph. The default when nothing structured is needed.

Required: text

{
  "component": "text@1",
  "props": {
    "text": "Your week is planned. Four dinners reuse what you already have."
  }
}
text@1

Your week is planned. Four dinners reuse what you already have.

stat@1

A few labelled figures side by side. For totals and summaries, never for prose.

Required: entries

{
  "component": "stat@1",
  "props": {
    "entries": [
      {
        "label": "Days",
        "value": "7"
      },
      {
        "label": "Avg kcal",
        "value": "2100"
      }
    ]
  }
}
stat@1
7Days
2100Avg kcal

media@1

One image or video with an optional caption.

Required: url, kind Optional: caption

{
  "component": "media@1",
  "props": {
    "url": "https://example.com/plate.jpg",
    "kind": "image",
    "caption": "Tuesday's dinner"
  }
}
media@1
image

Tuesday's dinner

list@1

Rows the user reads or taps. Each item needs a stable id, because that id is what comes back when they tap it.

Required: items Optional: title

{
  "component": "list@1",
  "props": {
    "title": "This week",
    "items": [
      {
        "id": "mon",
        "title": "Monday",
        "subtitle": "Oats, salad, salmon"
      },
      {
        "id": "tue",
        "title": "Tuesday",
        "subtitle": "Eggs, soup, stew"
      }
    ]
  }
}
list@1

This week

  • MondayOats, salad, salmon
  • TuesdayEggs, soup, stew

checklist@1

Rows the user ticks off — shopping, packing, anything with a done state. The checked flag is the program's, never the client's: a tap reports the intent and the program answers with the new state.

Required: items Optional: title, action

{
  "component": "checklist@1",
  "props": {
    "title": "Shopping list",
    "action": "toggle_bought",
    "items": [
      {
        "id": "milk",
        "title": "Milk",
        "subtitle": "1 l",
        "checked": true
      },
      {
        "id": "oats",
        "title": "Oats",
        "subtitle": "500 g",
        "checked": false
      }
    ]
  }
}
checklist@1

Shopping list

  • Milk
  • Oats

card@1

One thing presented on its own, optionally with a body component and action buttons.

Required: title Optional: subtitle, media, body, actions

{
  "component": "card@1",
  "props": {
    "title": "Week of 11 August",
    "subtitle": "2100 kcal a day",
    "body": {
      "component": "text@1",
      "props": {
        "text": "Four dinners under 30 minutes."
      }
    },
    "actions": [
      {
        "id": "swap-tue",
        "label": "Swap Tuesday"
      }
    ]
  }
}
card@1

Week of 11 August

2100 kcal a day

Four dinners under 30 minutes.

confirm@1

Ask the user to agree to one thing. Interactive: it ends the turn and their answer arrives as the next message.

Required: prompt Optional: confirm_label, cancel_label

{
  "component": "confirm@1",
  "props": {
    "prompt": "Replace Tuesday's dinner?",
    "confirm_label": "Replace",
    "cancel_label": "Keep it"
  }
}
confirm@1

Replace Tuesday's dinner?

choice@1

Ask the user to pick one of a few options. Interactive, like confirm.

Required: prompt, options

{
  "component": "choice@1",
  "props": {
    "prompt": "Which night should we change?",
    "options": [
      {
        "id": "tue",
        "label": "Tuesday"
      },
      {
        "id": "wed",
        "label": "Wednesday"
      }
    ]
  }
}
choice@1

Which night should we change?

form@1

Collect several values at once. Interactive; prefer it to asking question by question.

Required: fields Optional: prompt

{
  "component": "form@1",
  "props": {
    "prompt": "Tell me about your week",
    "fields": [
      {
        "id": "meals",
        "type": "number",
        "label": "Dinners to plan",
        "required": true
      },
      {
        "id": "notes",
        "type": "text",
        "label": "Anything to avoid"
      }
    ]
  }
}
form@1

Tell me about your week

Extending the kit

Add a component when a program genuinely cannot express something with what exists. Give it a schema, a purpose and an example; the guide and the UI agent's context are generated from those, so documentation cannot drift from the schema. Bump the component version rather than changing an existing one in place — a client in the wild still renders the old one.

50 · Shipping

Your program works. Getting it in front of people is three steps, and the first happens once.

claim your app id  ──▶  the id is yours, no versions, in no catalogue
                             │
                             ▼
                        submit a version  ──▶  pending, nobody can see or install it
                             │
                             ▼
                        a human reviews  ──┬──▶  accepted — it is in the store
                                           └──▶  rejected, with a reason you can act on

1. Claim your app id

curl -X POST https://<store>/apps \
  -H "x-appstore-publish: $PUBLISH_TOKEN" \
  -H 'content-type: application/json' \
  -d '{"app": "meal-planner", "publisher": "kitchenlab"}'

a-z, 0-9 and -, 3–64 characters. It is lowercased for you, and refused with 409 if somebody already holds it.

The id is permanent, and it is your identity. Every version you publish hangs off it, every install names it, and the pseudonymous subject you see for each user is derived from it. There is no rename: a new id is a new program to every user who has the old one. Choose it as carefully as a database name.

2. Submit a version

curl -X POST https://<store>/apps/meal-planner/versions \
  -H "x-appstore-publish: $PUBLISH_TOKEN" \
  -H 'content-type: application/json' \
  -d "{\"manifest\": $(cat manifest.json)}"
# {"submitted":"meal-planner","version":"2026-08-12T140104Z","status":"pending"}

Submitting to an id nobody claimed is a 404. That is the typo guard: without the separate claim, a mistyped name would quietly become a second program rather than an error.

You do not choose the version. Whatever your manifest's version field says is replaced with the instant the store received it — 2026-08-12T140104Z. It cannot collide, cannot go backwards, cannot lie, and it sorts as a plain string. Two submissions inside one second take the next free second.

Your version is pending and is in no catalogue. Nobody can see it, search it or install it.

3. Review

A human accepts or rejects it. A rejection always carries a reason — the API refuses one without, because a rejection that says nothing teaches nobody and the next submission is the same one again.

What the reviewer is reading for:

  • the description says what your program does, in the words a user would use. The orchestrator routes on this string alone. Vague descriptions get you called for the wrong things, or never;
  • consent_scopes are the minimum you need. Every scope is something a person is asked to grant;
  • **mcp_servers[*].url is HTTPS**, on a host you control. That host becomes yours, and no other program may claim it. Two programs may share a hostname under different paths;
  • **mcp_servers[*].audience is that server's own URL**, so tokens minted for you are rejected everywhere else;
  • budgets survive a bad turn, not just the demo one;
  • the prompt does not try to work around the platform — asking for credentials, emitting UI outside the vocabulary, or reaching past a scope.

Before you submit

  • Every writing tool declares writes: true. Check the list against your database, not your memory.
  • Consent scopes match reality. Every scope you ask for is used; every tool that needs one checks it.
  • Your MCP server validates the token via JWKS and checks its own audience is present in aud.
  • Rows are keyed by sub, and nothing else identifies a person anywhere in your storage.
  • Refusals are real. Medical, legal and financial edges get a plain refusal and a pointer to a professional, not a hedged answer.
  • You have run it against a person who is not you. Preferably one who writes in another language.

Updating: what reaches existing users, and what does not

An accepted update does not reach anybody who already installed your program.

Installing copies your manifest onto that user's record. What runs for them is the definition they agreed to, and it stays that way until they install again. Two reasons, and both are load-bearing:

  • a conversation must not depend on the store being reachable. Your definition is already on the runtime, so a store outage costs browsing and nothing else;
  • consent was given to a specific definition. If it could change underneath, a publisher adding a scope tomorrow would silently widen what every existing user already granted.

So, concretely:

you do thisexisting users get
add a consent_scopenothing, until they reinstall and grant it
remove or rename a toolnothing — their pinned copy still has it
ship a broken manifestnothing — only new installs are affected
fix a bug in your MCP serverimmediately — that is called live, and it is entirely yours

Put behaviour in your server, not your manifest. The manifest is the contract a user consented to; your server is the part you can fix this afternoon. A change that must reach existing users today belongs behind a tool, not in a new version.

Withdrawing is a yank, not a delete

A withdrawn version stops being offered to new installs and still resolves for anyone pinned to it. Nobody loses a program they already added.

There is no way to delete a program out from under its users, and that is deliberate. Their data was always yours, on your servers; removing your listing does not reach into their runtime.

Categories

Your manifest may suggest a category. It is a suggestion, applied when your app is first shelved if it names a category the store actually has. Where your program sits on the storefront is the store's decision, not yours, and resubmitting cannot move it.

What you can expect from the platform

  • Being accepted does not redeploy anything. Your program is installable the moment a reviewer accepts it. Nothing restarts.
  • Your tool results are treated as untrusted input, including by the agent that called you. Do not rely on steering the conversation through a return value.
  • You are one of several programs in one conversation. Answer your part well and let the orchestrator do the joining.
  • Install counts are people, not downloads. The number goes down when somebody removes you.