Skills
A skill is a capability: you call on it for a need, and it offers actions to choose from. Its declaration says how to get these actions, classify them, present them, reserve them and run them.
skills:
- name: refund
cognitive: # WHEN to use it — this is also what the ranking model reads
objective: "offer the authorised refund for the ticket's invoice"
diagnose: [the customer's reason, the invoice line]
params: # what the skill reads, and WHO can set it
invoice: {type: id, set_by: [read]}
max_amount: {type: price, set_by: [read]}
invoke: # how to get the possible actions
method: proposeRefund
args: {invoice: "$params.invoice"}
kinds: # how to classify them
partial:
when: "$fields.amount < $fields.invoice_total" # two fields of present, read on the row
select: {guards: [{approval: {role: support-manager}}]}
present: # what we keep, typed
fields:
name: {from: label, type: text, role: title}
amount: {from: amount, type: price, role: billed}
invoice_total: {from: invoice_total, type: price}
select: # how to reserve them
max_items: 1
unique: "$params.invoice"
guards:
- {name: plafond-facture, require: "$item.fields.amount <= $params.max_amount"}
- check: checkRefundPolicy
- {approval: {role: support-manager}, when: "$item.fields.amount > 50"}
submit: # how to run them
- name: pay
tool: createRefund
args: {body.amount: "$item.fields.amount", body.idempotency_key: "$action.key"}
output: {id: {from: "$output.id", type: id}}
render: {component: cards}
output_filters: {drop: [card_number]}
per_profile: {agent-support: {select: {guards: [{policy: remboursements-du-jour}]}}}
cognitive
objective says which need the skill answers; diagnose, what must be understood before making
an offer. This is not decoration: it is the description that seek_guidance reads to offer the
right skill.
params and set_by — where each value comes from
A skill reads only its own parameters. The playbook binds them to earlier reads. This way the same skill serves in several journeys.
set_by says who can set a parameter:
| source | meaning |
|---|---|
llm | the model, from the conversation |
person | the person, in a form or with a click |
read | a read in this room |
result | the result of an earlier submit |
identity | the identity of whoever acts |
Absent: [llm, person], as today. An amount with set_by: [read] cannot be invented by the
model; a recipient with set_by: [read, identity] cannot be hijacked by malicious content.
A parameter can have a default — for a skill called outside any journey. It is a literal: it is
read before the call, when no item exists yet.
Each parameter has a type, taken from the type table. A value
not of that type is not passed to the skill: an amount read as text does not become a price.
invoke
invoke:
method: getOvertimeReport
args: {team: "$params.team"}
guards: [{permission: {profiles: [manager, rh]}}] # a read has guards too
A skill whose operation is a read runs at once and leaves a trace. Its guards are declared
here, under invoke.guards: a read never goes through select.
invoke.method names only an operation of the station's APIs. A capability of Convoy itself is
cited like an operation, with invoke.native: search (a semantic search), routine (the
bundle engine), set (a set composed on request). A skill names one or the other, never both.
search names no source. What search and routine search over is not fixed yet: an index the
station fills separately, for a tenant that has no search operation, or the tenant's search
operation.
There is no catalogue in the config. What the station offers is what its skills' invoke
return, on each call, at the tenant. Searching what the tenant sells is a skill whose invoke is
the tenant's search operation, the question passed as a parameter:
params:
q: {type: text, set_by: [llm, person]}
invoke:
method: searchProducts
args: {q: "$params.q"}
An item is resolved by what was shown. A ref the room received from an invoke is known;
its name, price, image and link come from the present of the skill that showed it. A ref the
room never saw is refused: the agent shows it first, by calling the skill. Convoy keeps no copy of
what a tenant's invoke returns.
kinds
A kind is recognised by a condition on the action's fields. It can change select and submit,
and declare its line fields:
kinds:
pickup:
when: "$fields.type == 'pickup'"
submit: [{name: collect, mode: self}]
delivery:
when: "$fields.type == 'delivery'"
select:
guards:
- form: {address: {type: text, required: true}}
- check: checkDeliverable
- update: {price: getDeliveryPrice}
present:
fields:
name: {from: name, type: text, role: title}
type: {from: type, type: text}
A kind's when is read on the row, before it is an item: $fields.<name> there names a field
declared in the skill's present.fields, and nothing else. Everywhere else, a condition reads
$item.fields.<name>.
Line fields
A choice that belongs to one line, not to the selection — a dish's side dish — is a line
field. The kind declares it under fields, with a type taken from the
type table:
kinds:
dish:
when: "$fields.category != 'nems'"
fields:
variante: {type: id, required: true, label: "Accompagnement"}
- Each kind has its own fields: a nem and a dish do not carry the same ones.
- The person fills them line by line, in a form of the line's own. A value outside its type is refused.
requiredholds the submit, not the add: a line enters the selection unanswered, and says so; it does not leave for the tenant without the answer.- The field is on the item under its name (
$item.fields.variante), and the call that sends the line reads it as$line.fields.variante(the shape of a line). A name already taken bypresent.fieldsis refused at load.
present — typed fields
Each field has a name chosen by the station, a source (from, the field of the tenant's row;
a dotted path reads inside an object), a type, and sometimes a role and a class.
Everything that carries a value takes its type from the same table: a present field, a form
field (a form guard, the submit form), a parameter (params), a call's output (output), a field
of a line sent to the tenant, a field read through $read. A type outside the table refuses the
station at load.
| type | what Core does |
|---|---|
text | shows it |
email, phone | writes it in one spelling before it identifies a room |
number, integer | checks an answer is one (integer: a whole number) |
boolean | checks it; in the submit form, a required boolean is a consent, answered only by yes |
price | in the station's currency; the billed field is the line's price |
quantity | a whole number |
id | an identifier: text or a whole number |
url | an address; the link field is the item's page at the tenant |
image | the address of an image; the first one is the card's image |
datetime | a date, or a date and a time, as ISO 8601 text |
The role tells the card what to display where: title (the line's name, text or id),
billed (the billed price, price), headline (the figure of a report), link (the item's page
at the tenant, url). One field per role, at most. An item can have two price fields; only one
is billed. All are in the currency of global.
- Only what is declared is read. Without a
billedfield, the item has no listed price; without atitle, the line shows its ref. The card's image is the firstimagefield. - Every field is on the item under its own name (
$item.fields.<name>). - Three names are reserved:
nameis thetitle,pricethebilled,image_urlthe firstimage.code,slugandidare refused: Core writes the item's ref there. - A
templatestarts with{tenant_url}orhttp(s)://: a relative one would resolve against the host that draws the card.
page: {from: slug, type: url, role: link,
template: "https://maison-indigo.example.fr/article/{value}"}
The link's template has only two placeholders: {value}, the field's value (encoded), and
{tenant_url}, the deployment's address. Without {value}, or with any other placeholder, it is
refused. Without a template, the value is the link when it is an absolute http(s) address.
Without a link field, the card has no link: Convoy never invents an address on the tenant's
domain.
The class (public, internal, personal, confidential, secret) says where the value can
go: see Data.
select
| key | effect |
|---|---|
max_items | 1: only one; the choice replaces |
guards | the ordered list of guards: see Guards |
unique | a key locked across the whole station while an action holds it |
valid_for | beyond it (30d), the action is checked again before the submit |
quantity | {min, max, step}: the quantities a line accepts; max and step optional |
quantity is most often set per kind: at least five nems, a dish one at a time.
kinds:
nems:
when: "$fields.category == 'nems'"
select: {quantity: {min: 5}}
Core holds the rule at add, at a quantity change and at submit. A quantity outside the rule is
refused, never silently corrected: the sentence states the minimum or the step, and the tenant
can rewrite it (The station's sentences). step is
the step (step: 6 for boxes of six); max, the ceiling. The quantity is not
read in a condition ($item.quantity): a guard sees only one add, and a second add or a quantity
change would reach the line without it.
submit — named calls
submit:
- name: open
tool: openFormationDossier
args: {body.capital: "$item.fields.capital", body.idempotency_key: "$action.key"}
output: {company_id: {from: "$output.company_id", type: id}}
- Each call has a name and declares its outputs. Others cite it by task and name:
$submitted[societe.open].company_id. - Several calls run in order. A call that reads the output of a failed call is put on hold.
mode:request(default),handover(withurl),self.$action.key: an idempotency key per action, stable from one attempt to the next. Several lines filed by one call are one action and share one key.- Only declared outputs can be read:
output: {company_id: {from: "$output.company_id", type: id}}keeps that path of the tenant's answer, with its type, and nothing else. line:says, on the call, the shape of one line sent to the tenant:line: {product_id: "$line.ref", quantity: "$line.quantity"}, orline: "$line.ref"for a tenant that expects a list of identifiers. See The shape of a line.- A call that files one action of this skill lives here, or on its kind: a refund, a delivery, a pickup. An order that sends the lines of several tasks in one request (the dishes, the drink, the cutlery) is declared once, on the playbook (The submit). A skill used outside any playbook keeps all its calls.
render and output_filters
render.component: carousel, cards, checklist, report. The component asks fields for
roles, never positions.
output_filters acts before the model sees the result:
output_filters:
drop: [iban, card_number] # removed
reduce: {address: district} # "Plateau, Dakar" instead of the full address
policies: [conseil-juridique] # a smart policy on the response (effect: rewrite)
Possible policies
A skill carries its rules where they apply (Where a rule applies):
| phase | where you write it | examples |
|---|---|---|
| read | invoke.guards | a permission on a read, a smart policy on what is asked |
| select | select.guards, per kind (kinds.<k>.select) or per profile (per_profile) | form, check, require, permission, approval, {policy: name}, a budget |
| submit | the submit calls; the submit rules live in the playbook's submit_guard, or the station's outside a playbook | the order, the dependencies; submitted_by in the submit_guard (The submit) |
| output | output_filters | mask, rewrite on what comes out of a read |
In select, unique (the same action only once) and valid_for (an action that expires);
a skill can be used only by the profiles granted the operations it calls (Permissions). See Data.
per_profile
The same points, specialised for a category of actors, as a kind specialises them for a category of
actions (kinds). See
Profiles for actors, Policies for the points.