Skip to main content
Version: Target · roadmap

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:

sourcemeaning
llmthe model, from the conversation
personthe person, in a form or with a click
reada read in this room
resultthe result of an earlier submit
identitythe 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.
  • required holds 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 by present.fields is 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.

typewhat Core does
textshows it
email, phonewrites it in one spelling before it identifies a room
number, integerchecks an answer is one (integer: a whole number)
booleanchecks it; in the submit form, a required boolean is a consent, answered only by yes
pricein the station's currency; the billed field is the line's price
quantitya whole number
idan identifier: text or a whole number
urlan address; the link field is the item's page at the tenant
imagethe address of an image; the first one is the card's image
datetimea 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 billed field, the item has no listed price; without a title, the line shows its ref. The card's image is the first image field.
  • Every field is on the item under its own name ($item.fields.<name>).
  • Three names are reserved: name is the title, price the billed, image_url the first image. code, slug and id are refused: Core writes the item's ref there.
  • A template starts with {tenant_url} or http(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​

keyeffect
max_items1: only one; the choice replaces
guardsthe ordered list of guards: see Guards
uniquea key locked across the whole station while an action holds it
valid_forbeyond 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 (with url), 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"}, or line: "$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):

phasewhere you write itexamples
readinvoke.guardsa permission on a read, a smart policy on what is asked
selectselect.guards, per kind (kinds.<k>.select) or per profile (per_profile)form, check, require, permission, approval, {policy: name}, a budget
submitthe submit calls; the submit rules live in the playbook's submit_guard, or the station's outside a playbookthe order, the dependencies; submitted_by in the submit_guard (The submit)
outputoutput_filtersmask, 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.