From the current version to the target
What changes in the file
| current | target | why |
|---|---|---|
tenant_id, store_name, domain | station: {id, kind, name} | an organisation owns several stations |
global: {language, currency, locale} | global.timezone as well | a sub-identity's hours, the inboxes' deadlines |
agent: {name, persona, tone, rules, emoji, model} | agent.mode as well; the rules that hold for every agent become policies | agent.rules still reach both doors; only how our agent speaks should stay there |
agent.portals.web.widget: {origins, theme} | the same shape; identity.auth as well | the tenant's site is a portal of the agent, not the station |
| — | agent.portals: {web, teams, slack, whatsapp} with identity.auth | new portals, and the proof of who is talking |
| — | station.identity.providers (otp, oidc, assertion, each with verifies:) and identifies: true on a form field | an identified room (declared), then authenticated (verified). identifies: true already exists in the current version |
apis[].operations: [{id, access, reversible, exposed}] | risk and audience added | the risk, what goes out. access, reversible and exposed already exist in the current version |
skills[].invoke: {method, args} or {native}, params: {x: {default}} (default optional) | invoke.guards; set_by and type on a parameter | who can set a parameter, what type its value is, and a read has its guards. The single declaration (invoke, kinds, present, select, submit, render), invoke.native, invoke.args, $params read by invoke.args and by the submit calls, a parameter whose value is its default or a task's binding already exist in the current version |
catalog: {from_skill, mode, query_arg} at the station level | —: the config has no catalogue; an item is resolved by what an invoke showed in the room, and read through the present of the skill that showed it | the invoke already says what the station offers; a second declaration would contradict it, and an indexed copy would go stale |
a skill's present.fields: {from, type, role, template} | the same block; the headline role and the class added | typed fields, their roles (title, billed, link) and the table of types, shared with forms, already exist in the current version; an item is read through the present of the skill that showed it |
kinds: {x: {when, select, submit}} | kinds.<x>.present added (a permission on what a profile sees); kinds.<x>.fields, the line fields the person fills line by line | a kind is recognised by a condition on a presented field; this already exists in the current version. A choice that belongs to one line (a dish's side dish) had nowhere to be written |
select: {max_items, guards: [form, require, check, update]}, each guard with when | permission, policy, approval added to guards; name, effect, mode, reason on every guard; unique, valid_for; quantity: {min, max, step}, most often per kind | an ordered list, open to other guards. The list, require, when and the condition language already exist in the current version |
submit: [{name, mode, tool, args, output, line_fields, line_shape}], one call per skill or per kind | several calls, run in order; each output typed: output: {x: {from, type}}; line: replaces line_fields and line_shape ($line.ref, $line.quantity, $line.price, $line.fields.<field>); extra_lines | chain writes inside one skill; one type table for everything that carries a value. Named calls, their outputs and $action.key already exist in the current version |
$submitted[task.call].output: a playbook's task | $result[tache], $read[tache] added | what a task read or returned. $submitted[task.call] already exists in the current version |
playbooks[]: {name, goal, tasks: {name: {skill, kind, one_of, choose, all_of, when, required, label, after, params}}} | one_of: {choose, fallback, methods: {name: {skill, kind, when, label, params}}}: choose inside the one_of, a label per method; no more all_of, after: [a, b] waits for both; when on $read (not on $memory or $submitted); choose: clm; params bound to $read or $result; depends_on, on_open, guards on a task | the condition on data, the data, a method's name as the person reads it. Named tasks, goal, label, after, the choice (one_of, choose: customer|rule, written next to one_of), when on $selection.kinds, a task's (and each method's) params bound to $submitted[task.call] with the after they imply, and the order enforced on choice already exist in the current version |
— (the agent opens the playbook: auto_trigger, start_playbook) | activation: {when, intent, exclusive_skills} | a playbook that opens and is law |
the station's submit_guard | submit_guard per playbook, with submitted_by | who submits, and under which rule |
| an order declared on each skill whose lines it sends, grouped by operation at submit | submit on the playbook: the call that sends the lines of several tasks, declared once, its lines taken by $selection[task=…]; cited $submitted[<call>] | two copies of one call could diverge, and the body then depended on the order of the lines |
| — | profiles with grants | required as soon as there is a skill: the migration writes an anonyme profile granted the operations the existing skills call, one by one |
agent.portals.web.widget.labels, which does not reach refusals | texts: {fr: {<key>: "…"}} at the station level, for refusal and hold sentences | the model reads these sentences too: they hold on every door |
| — | policies, inboxes, events, automations, tests, evals | new, all optional |
The previous form of playbooks is not read: the tool rewrites it (python -m convoy_core.config.migrate). Each item of steps becomes a task named after its skill, with
no after: the order of a list does not state a dependency, the author writes it if they want
it. description becomes goal; trigger is dropped, and its intent becomes the goal of a
playbook that had no description. $submitted[<skill>.<call>] becomes
$submitted[<task>.<call>] when the skill is the action — or a one_of method's — of tasks of a
single name. An all_of goes away: each task whose after names it waits for its members
directly. choose and fallback move inside the one_of, and its methods go under methods.
The catalogue. The tool removes catalog. Nothing replaces it: search goes through a skill
whose invoke is the tenant's search operation (the question under a parameter, instead of
query_arg), and a reference is resolved by what the room has already seen. mode: indexed has
no equivalent: Convoy does not index a tenant's catalogue (the case of a tenant with no search operation stays open).
The submit too. line_fields: {ref: a, quantity: b} becomes line: {a: "$line.ref", b: "$line.quantity"} (a missing key keeps slug or qty); line_shape: ref becomes
line: "$line.ref"; with neither, nothing is written: the default shape is the same. Calls of one
operation carried by the skills of several tasks of a playbook become a single call in that
playbook's submit, when their args and line shape are the same: $selection[kind=k] there
becomes $selection[task=…], and the references $submitted[<task>.<call>] to it become
$submitted[<call>]. Different args, or a skill also used outside any playbook, are left to the
author, with the reason. A migrated config must send the tenant the same requests, byte for
byte.
The build order
A step ships only when its examples from this version pass in the current version.
- Action states (pending, read) and read traces, in the room and the receipt.
- The order enforced on choice; tasks (
after,one_of,when); the planner (named tasks,goal,label,after,one_ofwithchoose: customerorrule,whenon the chosen kinds and the order enforced on choice,step_open, are built;choosewritten inside theone_of, a method'slabel, the removal ofall_of,whenon$read,choose: clmand steering the model toward the open tasks follow). - The new form of skills, the old one read for one version; the six configs migrated (the ordered list of
selectguards,require,whenand the condition language are built; the skill's single declaration is built, withparamsand theirdefault,invoke.args,invoke.nativeandkinds.<k>(when,select,submit), and a task'sparamsbound to$submitted[…]). - The submit guard per playbook, with the playbook's order call (
submit,line:,extra_lines) and line fields; then each playbook's state graph, compiled at load, before the guidance; sub-playbooks; thenresultand$result, which read a sub-playbook. - The journey attached to the room; approvals tied to a version of the action;
valid_for. - Profiles,
per_profile, permissions; per-person identity (the identity plane,identifiesand theotpprovider via the tenant are built;via: convoy,oidc,assertion, the choice per portal, authentication requests andsubmitted_by: ownerfollow). approval, when a room hosts several people with roles.submitted_by, when actors are authenticated; then delegated sub-identities.set_by, data classes, audiences; read guards.- Deterministic activation,
seek_guidancewithout a model, the first-write rule. - Idempotency per action,
unique,reversible,budget,inboxes($action.keyis built;reversibleis declared and makes the MCP submit destructive; the approval screen and the submit order follow). - Automations, with their profile and loop checking; the
agentsection and its portals (theagentsection andagent.portals.web.widget: {origins, theme}are built; Teams, Slack, WhatsApp next to the widget and the PWA follow). - Station versions, staging, contract tests, replay; the agent's
evals. - The ranking model in shadow, then in SUGGEST, then in ENFORCE, station by station.
Where these decisions come from
- ADR-028 §6 — a playbook as a set of tasks and four relations.
- ADR-029 — everything is a station; the action; the selection as an order of actions; sub-playbooks; who submits.
- ADR-030 — the decision point, the planner, the ranking model; profiles;
seek_guidance; provenance, data classes, time, execution, the Human In The Loop, automations.
Some keys in this guide are proposals the ADRs have not yet settled: the
station block, global.timezone, auth.on_behalf_of, station.delegation, agent.portals and
identity.auth, the syntax of tests and evals. They
may change at implementation; the meaning will not.