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.
| # | Document | Read it when |
|---|---|---|
| 00 | What you build | You want the model: what you ship, what MILA owns, what you can and cannot reach. |
| 05 | Your first program | Start here. Nothing to something a person can install, with the code. |
| 10 | The manifest | You are writing the one JSON file that is your program. |
| 20 | Your MCP server | You are implementing the service MILA calls, and the auth it arrives with. |
| 30 | Talking to people | You want to know what your program can put on screen and when it may speak first. |
| 40 | Components | You are choosing what to render. Every component, with its payload. |
| 50 | Shipping | Your 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
| # | What | Notes |
|---|---|---|
| 1 | The manifest, one JSON file | Identity, agent prompt, tool bindings, budgets, consent scopes, pages. Nothing is configured outside it. |
| 2 | A reachable MCP server over HTTPS | Validates MILA's token, authorises each tool on scope, keys all data by sub. |
| 3 | The audience string it expects | Normally 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_nameanddescriptionare 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
| v0 | note | |
|---|---|---|
| program manifest | allowed | the schema is normative |
| agent definition: prompt, budget, tools | allowed | |
| nested agent as a tool | allowed | depth ≤ 3, budget required at every level |
| MCP servers over HTTPS | allowed | http transport only |
| UI tools | allowed | a fixed set of seven |
| proactive intent | allowed | requires the proactive consent scope |
| consent scopes | allowed | granted at install |
| budgets | required | every agent and nested agent |
| third-party account passthrough | reserved | no program touches a user's external account in v0 |
| your own UI components | reserved | the namespace is held; not designed |
| open network MCP discovery | forbidden | the reviewed store is discovery in v0 |
| your code on the device or inside MILA | forbidden | no escape hatch exists |
| styling, layout, branding | forbidden | no such field exists in any schema |
| local MCP transports | forbidden | providers are remote services |
"Reserved" means the extension point is designed so that adding it later is additive, not breaking.
05 · Your first program, end to end
From nothing to something a person can install. Roughly an hour, most of it spent on your own domain logic rather than on us.
Read What you build first if you have not — this assumes you know that you ship a manifest and an HTTPS service, and that nothing of yours runs on the device or inside MILA.
Before you start
- A Google account. It signs you in to the console. There is no separate account to create.
- A way to be reached over HTTPS. MILA calls your server; a laptop is not on the internet. During development that means a tunnel —
cloudflaredorngrokwill give you a public URL for a local port in a few seconds. No amount of protocol design removes this step, so it is the first one.
1. The smallest server that works
Two tools, one of which writes. Python here; any language with an MCP implementation is fine.
The token arrives as an HTTP header, not as a tool argument. MILA calls you with Authorization: Bearer <JWT>, so you read it from the request, once, in middleware — never as a parameter on a tool. A token parameter would be one the model has to fill, and the model never sees the token.
import contextvars
import os
import requests
from jose import jwt
from mcp.server.fastmcp import FastMCP
from starlette.applications import Starlette
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from starlette.routing import Mount
MILA_JWKS = "https://api.mila.devbm.org/.well-known/jwks.json"
MILA_ISSUER = "https://api.mila.devbm.org"
MY_AUDIENCE = os.environ["MY_AUDIENCE"] # your own https URL
mcp = FastMCP("reading-list", stateless_http=True, json_response=True)
BOOKS = {} # a real one would use a database
_claims = contextvars.ContextVar("claims")
class Auth(BaseHTTPMiddleware):
"""Every request, before any tool runs. Signature, then audience."""
async def dispatch(self, request, call_next):
header = request.headers.get("authorization", "")
if not header.startswith("Bearer "):
return JSONResponse({"error": "missing bearer token"}, status_code=401)
try:
claims = jwt.decode(header.removeprefix("Bearer "),
requests.get(MILA_JWKS, timeout=5).json(),
algorithms=["RS256"], audience=MY_AUDIENCE,
issuer=MILA_ISSUER)
except Exception as exc:
return JSONResponse({"error": f"invalid token: {exc}"}, status_code=401)
token = _claims.set(claims)
try:
return await call_next(request)
finally:
_claims.reset(token)
def who(scope):
"""The third check. The manifest says what you may be asked for; `scope`
says what this person agreed to."""
if scope not in _claims.get().get("scope", "").split():
raise PermissionError(scope)
# Pairwise: the same human is a different id to every program, so nobody
# can join across providers. It is the only id you get, and the only one
# you should store.
return _claims.get()["sub"]
@mcp.tool(description="What is on the reading list.")
def list_books() -> str:
return ", ".join(BOOKS.get(who("books.read"), [])) or "nothing yet"
@mcp.tool(description="Add a book to the reading list.", annotations={"writes": True})
def add_book(title: str) -> str:
BOOKS.setdefault(who("books.write"), []).append(title)
return f"added {title}"
app = Starlette(routes=[Mount("/", mcp.streamable_http_app())])
app.add_middleware(Auth)
Four things are non-negotiable, and each fails quietly if you skip it:
- read the token from the
Authorizationheader, never as a tool parameter; - verify the signature against JWKS, never a shared secret;
- check your audience is present in
aud— present in, not equal to; - authorise every tool on
scope. The manifest says what you may be asked for;scopesays what this person agreed to. Different questions.
Your tools take only their own domain arguments. Nothing about identity is ever a parameter.
Key every row by sub and nothing else. It is pairwise, so the same person is a different id to every program — which is exactly what stops two providers comparing notes about them.
2. The manifest
One JSON file. It is the whole of what MILA knows about you.
{
"protocol": "mila/v0",
"program": "reading-list",
"publisher": "yourco",
"display_name": "Reading List",
"tagline": "Keep track of what you meant to read.",
"description": "Keeps a reading list. Use this whenever the user talks about books they want to read, are reading, or have finished.",
"icon": "📚",
"category": "other",
"consent_scopes": ["books.read", "books.write"],
"mcp_servers": {
"books": {
"transport": "http",
"url": "https://your-tunnel.example.com/mcp",
"audience": "https://your-tunnel.example.com"
}
},
"agent": {
"prompt": "You keep the user's reading list. Add what they mention wanting to read, and tell them what is on it when they ask. Never invent a book they did not mention.",
"budget": {"max_tokens": 40000, "max_tool_calls": 10},
"tools": [
{"type": "mcp", "server": "books", "name": "list_books"},
{"type": "mcp", "server": "books", "name": "add_book", "writes": true}
]
}
}
Two fields decide whether your program ever gets used:
description is how MILA routes. It is read by the orchestrator, not by a person, and it is the only thing that decides whether your program is called at all. Say what jobs you do, in the words somebody would use. A reading list that never says "books" does not get called about books.
tagline is for people. It is what appears on your card in the store.
You may include version; it is ignored. The store stamps the moment it received your upload, so it cannot collide, go backwards, or lie.
3. Claim your id
Sign in at <https://portal.mila.devbm.org> with Google, register as a publisher, and claim reading-list.
The id is permanent. Every version hangs off it, every install names it, and the pseudonymous subject you see for each user is derived from it — so there is no rename, and a new id is a different program to everyone who has the old one. Choose it as carefully as a database name.
A taken id is refused immediately, which is the point of claiming being its own step: a typo becomes an error rather than quietly becoming a second program.
4. Upload, and run it yourself
Paste the manifest into Upload a version. It lands private — yours to install, nobody else's to see, and in no catalogue.
Press Make testable, then open MILA on your phone: Settings › Beta testing. Your own build is there without an invitation, because testing your own work should not require inviting yourself.
Install it and talk to it. This is the loop: edit, upload, install, talk. Nobody reviews anything until you ask.
5. Invite up to three people
Testers on your app's card. They get an email with a six-character code, and enter it in the same place: Settings › Beta testing.
Seats are finite and a pending invitation holds one, so invitations expire after a week and free the seat. You can revoke one at any time — a typo'd address should not cost you a seat until it lapses.
Testers get your updates silently. Upload a new version, make it testable, and their next message uses it. That is what testing means; a tester who has to accept every build is doing release management instead.
6. Submit for review
Submit for review on the version you want released. A human reads it and either accepts, or rejects with a reason you can act on.
What they are reading for is in Shipping — mostly: does the description say what you actually do, are the scopes the minimum you need, and is the audience your own server.
Accepted means it is in the store, installable, and appears on the shelf the reviewer put it on. Nothing restarts.
7. What happens next, and what does not
Your updates reach people automatically. A published version is picked up on each person's next message, so a fix lands within a turn.
Except when it widens what you may do. A new consent_scope reaches nobody until they grant it. Tokens are minted from what the person agreed to, never from your manifest, so a version asking for more simply does not get more.
Put behaviour in your server, not your manifest. Your server is called live and is entirely yours — a bug fixed there is fixed for everyone immediately. The manifest is the contract somebody consented to, and changing it is a heavier act.
Withdrawing is a yank, not a delete. A withdrawn version stops being offered and still resolves for anyone already on it. You cannot remove a program out from under its users, and you do not need to: their data was always on your servers.
When it does not work
The app says the program is unavailable. MILA could not reach your MCP server this turn. Your tunnel died, or TLS is wrong. The conversation carries on — one program being down costs that program, never the whole conversation.
Your tools are never called. Read your description as the orchestrator does. If it does not say, in ordinary words, what jobs you do, you do not get called.
Every call is rejected. Almost always the audience: aud contains what you declared, and your check must look for your value inside it rather than comparing the whole claim.
Your submission is refused with 409. Another program already claims that host. Two programs may share a hostname under different paths, but not the same one.
10 · The manifest
One JSON file is your entire program. Normative schema: manifest.schema.json.
{
"protocol": "mila/v0",
"program": "meal-planner",
"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: the store replaces it with the instant your submission arrived. See Shipping.
Leaving it out is the only safe choice. The field is still validated before it is replaced, and the schema accepts one shape only — 2026-08-12T140104Z, the stamped format. A human version like "0.1.0" is rejected outright, so a manifest carrying one never reaches the step that would have overwritten it.
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
mcptool MUST reference a declaredmcp_serversentry; - 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.enabledMUST be accompanied by theproactiveconsent 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.
- 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.
- What is the durable state, and what shape is it? Write the JSON of the stored object now. It becomes your tool signatures.
- Which consent scopes does the user grant at install? One scope per capability a user would recognise as a decision. Not one per tool.
- Does the program ever speak first? If yes, you need the
proactivescope and a worker. - 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_formonce — 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
There is a model field, and you may express a preference in it — but you do not get to decide. 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. So the field is a request that may be silently declined: write prompts that do not depend on the quirks of one model.
What the store shows
Four fields exist only to make your card readable, and none of them affects routing:
| field | what |
|---|---|
display_name | the name on the card, 60 characters |
tagline | one line for a person deciding whether to add you, 90 characters |
icon | a single emoji. Emoji rather than an image URL: no upload, no CDN, renders everywhere |
gallery | up to six screenshots for your detail page, each {url, caption}, in order |
category suggests a shelf, and is covered in Shipping — read that before you guess a value, because guessing wrong fails silently.
Pages
views declares screens the user can open at any time. It is covered in Talking to people, along with accepts_images.
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>.
| claim | meaning |
|---|---|
iss | MILA's issuer |
aud | your MCP server — the audience you declared |
sub | pairwise pseudonymous user id, different per program |
program_id | the calling program |
scope | the scopes the user consented to at install |
exp | short-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;scopesays 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 stores | You store |
|---|---|
| conversations, messages | every domain object you have |
| which programs a user installed, and the scopes granted | user profiles, plans, preferences, history |
| the proactive queue | your catalogues and computed results |
| which pages you declare, and which tool backs each | what 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.
| tool | kind | returns |
|---|---|---|
ui.show_text | presentational | immediately |
ui.show_card | presentational | immediately |
ui.show_list | presentational | immediately |
ui.show_media | presentational | immediately |
ui.pick_from_list | interactive | the user's choice |
ui.ask_form | interactive | the field values |
ui.confirm | interactive | yes 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, in the manifest's views array. 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 one of your tools when the page is opened.
"views": [
{ "id": "plan", "title": "This week", "tool": "render_plan" },
{ "id": "shopping", "title": "Shopping list", "tool": "render_shopping_list",
"on_action": "shopping_action" }
]
| field | meaning |
|---|---|
id | stable identifier for the page |
title | what the user sees on the tab |
tool | the tool MILA calls to draw it. It MUST also appear in agent.tools |
on_action | optional. The tool called when the user acts on the page — ticking a checklist item, say |
The backing tool returns the page as JSON, and returning components is what draws them — there is no separate render call:
{
"title": "Your week",
"subtitle": "Week of 11 August",
"components": [ { "component": "stat@1", "props": { "entries": [ … ] } } ]
}
Return "empty": true alongside a single text@1 when there is nothing yet. An empty page that explains how to fill it is worth more than a blank one.
on_action is how a page accepts a tap. The tool receives {action, item_id, value} and MUST return the whole page again. The checked state is yours, never the client's: the tap reports an intent, you store it, and the page you return is what the user sees. A page without on_action is read-only, which is the right default.
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.
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.
Components in conversation, components on a page
The seven UI tools cover what an agent can say. The component vocabulary is wider than that — stat@1 and checklist@1 have no UI tool of their own, and are reachable only by a tool returning components, as a page does. If you want a figure row or a tickable list, it belongs on a page.
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 — a reason to speak — and MILA runs its own agent to decide what the person reads. That agent asks yours for whatever it needs to write something worth reading, then either sends one message or stays silent. The rules are normative:
- the user is addressed only by the pairwise
sub— never email, phone or device token; - who the intent is for comes from the credential you present, never from the request body;
- 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); - you can never write text or UI directly into a conversation.
Send facts, not a message. A message_to_user field in your payload does not become the message — it is read as data about your event. The words are MILA's.
Your credential. A worker holds nothing when it wakes: the tokens MILA mints for your MCP server last five minutes. So while you are being called for someone, exchange one for a durable credential and store it against your own record of them.
POST /proactive/credential Authorization: Bearer <the live call token>
→ { "credential": "…", "expires_in_days": 180 }
POST /proactive/intents Authorization: Bearer <that credential>
Idempotency-Key: streak-2026-W33
{ "payload": { "kind": "streak_at_risk", "streak_days": 6 } }
It names one person and one program, and grants nothing by itself — every intent is re-checked against the live install, so an uninstall stops it whatever the expiry says.
Idempotency-Key names the event, so a retry is not a second message. Workers retry — a response that times out after we committed, a restart, a timer firing twice across a clock change — and none of those are new events. A replay answers 200 with "status": "duplicate" and the original intent id, and does not spend your daily quota. Key it by the event (plan-due-2026-W33), never by the attempt: a fresh UUID per call is a new key every retry and protects nothing. Send no key and every call is its own intent, which is right when your events genuinely repeat.
Silence is a normal outcome. If your agent answers that there is nothing pending, nothing is sent. Only one program is ever reachable while an intent is handled — yours, the one that raised it.
There is no per-program mute and no quiet hours. A person who does not want your messages uninstalls you, which takes everything else with it. So the question for every intent is not "may I send this" but "is this worth spending the only attention I get" — the answer to a bad one is not a mute you could recover from.
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 — "accepts_images": true, at the top level of the manifest. 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. Absent or false means you never see one, however many are attached.
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 contracts/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.
Not every component has a UI tool. The UI tools cover what an agent can say in conversation; components without one — stat@1 and checklist@1 — are reachable only by a tool that RETURNS components, which is what a declared page does. If you want a figure row or a tickable list, it belongs on a page rather than in a reply.
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."
}
}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"
}
]
}
}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"
}
}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"
}
]
}
}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
}
]
}
}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"
}
]
}
}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"
}
}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"
}
]
}
}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"
}
]
}
}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
The reference for what each step means and why. If you are doing this for the first time, Your first program walks the whole thing through with the code.
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
In the console at <https://portal.mila.devbm.org>, signed in with Google. Or, to automate it:
curl -X POST https://portal.mila.devbm.org/apps \
-H "x-portal-publish: $PUBLISH_TOKEN" \
-H 'content-type: application/json' -d '{"app": "meal-planner"}'
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. Upload a version, then ask for review
Two acts, not one — which is what lets you run a build before anybody reviews it.
P=https://portal.mila.devbm.org
H="x-portal-publish: $PUBLISH_TOKEN"
# lands `private`: yours to install, nobody else's to see
curl -X POST "$P/apps/meal-planner/versions" -H "$H" \
-H 'content-type: application/json' -d "{\"manifest\": $(cat manifest.json)}"
# {"uploaded":"meal-planner","version":"2026-08-12T140104Z","status":"private"}
# open it to yourself and up to three invited testers
curl -X POST "$P/apps/meal-planner/versions/2026-08-12T140104Z/testing" -H "$H" \
-H 'content-type: application/json' -d '{}'
# and when it is ready, ask a human to look
curl -X POST "$P/apps/meal-planner/versions/2026-08-12T140104Z/submit" -H "$H" \
-H 'content-type: application/json' -d '{}'
Uploading 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 your upload arrived — 2026-08-12T140104Z. It cannot collide, go backwards or lie, and it sorts as a plain string.
A submitted version is in no catalogue. Nobody can see it, search it or install it until it is accepted.
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
descriptionsays 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_scopesare the minimum you need. Every scope is something a person is asked to grant;- **
mcp_servers[*].urlis 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[*].audienceis 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 people, and what does not
A released version reaches everyone. People who already installed your program pick it up on their next message — you do not need them to reinstall, and there is nothing for them to accept. A fix lands within a turn.
Except when it widens what you may do. Anything that asks for more than the person already agreed to does not apply to them:
| you do this | people already using it get |
|---|---|
| fix a bug, change the prompt, add or remove a tool | it, on their next message |
| change the display name, tagline, icon | it, on their next message |
add a consent_scope | nothing, until they grant it |
The permission a person granted is the permission your tools are called with. It is recorded when they install and is never re-read from your manifest, so a new version asking for more simply does not receive more. That is not a check you can fail — there is no path by which a manifest grants itself anything.
Put behaviour in your server, not your manifest. Your server is called live and is entirely yours, so a fix there is immediate and needs nobody's approval. A manifest change is a release: reviewed, and slower by design.
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.
Check the list before you guess, because a wrong value fails silently. The store's shelves are not an enum in the schema — a storefront must be able to add one without a new protocol version — so a category that does not exist is not a validation error. Your app publishes successfully, with no category, on no shelf, and installable only by someone who already knows its name. Nothing warns you at submit or at review.
The shelves are whatever GET /categories on the store returns — at the time of writing health,
food, money, home and a few more. Ask it; do not infer a plausible-sounding slug. "finance" is exactly the sort of guess that looks right and is not.
Two consequences worth knowing before you submit rather than after:
- it is read once. The suggestion applies on your app's first submission only. A corrected manifest cannot move a shelved app, or shelve an unshelved one;
- the fix is an operator's, not yours. Once it is wrong, only the store can place you.
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.