A composition is the capability half of a pack: one surface Recued can talk to, and the operations a recipe may call against it. The Pack editor produces one for you, and most authors never read this page. It is here because you may want to generate a pack — from an API description, from a script, or from a tool of your own — and because publishing an artifact you did not hand-write is a supported path.
Everything below is what publish and install actually validate.
#Where it sits
{
"manifest_version": 2,
"artifact_type": "pack",
"pack_kind": "app_pack",
"slug": "acme-tasks",
"publisher": "your-publisher-handle",
"name": "Acme Tasks",
"description": "Read and act on Acme tasks.",
"version": 1,
"recipes": [],
"tags": ["pack:acme"],
"contents": [{ "type": "composition", "composition": { } }]
}publisher must equal the handle you are authenticated as when you publish. See
the pack manifest for the rest of the envelope.
#The composition
{
"schema_version": 1,
"slug": "acme-catalog",
"catalog_kind": "official",
"ingredients": [],
"operations": []
}| Field | Notes |
|---|---|
schema_version |
1 |
slug |
Required — the catalog identity |
catalog_kind |
official, unofficial_acknowledged, or private_byo — how authoritative your description of the vendor is |
ingredients |
Exactly one row. See below |
operations |
At least one row |
One composition describes one surface, so ingredients carries a single entry.
A vendor with two incompatible API versions is two packs, and the second one
depends on the first rather than adding a second ingredient.
#Ingredients — what you can talk to
{
"slug": "acme-catalog",
"kind": "http",
"http": {
"base": "https://api.acme.example",
"connection": "acme",
"result_path": "items"
},
"entities": {}
}| Field | Notes |
|---|---|
slug |
Joins to each operation's ingredient |
kind |
http, cli, connection, mcp, or storage |
http.base |
Origin only. Put the full path in each operation's path_template |
http.connection |
Which connection record supplies credentials |
http.result_path |
Surface-wide default for where records live in a response; an operation may override it |
entities |
Optional vendor entity schemas, if your pack projects records into a canonical shape |
⚠ Keep base origin-only. A base carrying a version prefix plus an
operation path that starts with / drops the prefix, because that is what URL
resolution does. Both forms compose correctly today, but origin-only is the
convention and it is the one that never surprises anyone.
A kind: "storage" ingredient is a pack with its own local rows rather than a
remote API; see Authoring ingredient
packs for what that substrate can and
cannot do.
#Operations — what a recipe can call
[
{
"op": "task.list",
"ingredient": "acme-catalog",
"risk": "read",
"approval": "never",
"idempotency": "safe",
"description": "List my open Acme tasks.",
"args": [
{ "key": "query.assignee", "type": "string" }
],
"bind": {
"kind": "rest",
"method": "GET",
"path_template": "/v1/tasks",
"static_query": { "limit": "100" },
"static_headers": { "Accept": "application/json" }
},
"result_path": "items",
"cache_ttl_ms": 60000
},
{
"op": "task.complete",
"ingredient": "acme-catalog",
"risk": "write",
"approval": "ask",
"idempotency": "idempotent",
"description": "Mark one Acme task complete.",
"args": [
{ "key": "task_id", "type": "string", "required": true, "affects_target": true },
{ "key": "body.note", "type": "string" }
],
"bind": {
"kind": "rest",
"method": "POST",
"path_template": "/v1/tasks/{{task_id}}/complete"
}
}
]The read takes a filter and no target; the write takes the identity of the one record it changes, and says so.
| Field | Notes |
|---|---|
op |
The name a recipe calls, as publisher.pack.op once installed |
ingredient |
The ingredient slug above |
risk |
read, write, admin, or destructive |
approval |
never, ask, or always |
idempotency |
safe, idempotent, or non_idempotent |
description |
What a model reads when deciding whether to pick this |
args |
The public input surface — see below |
bind |
The execution binding — see below |
result_path |
Where records live in this operation's response. Per-operation wins over the surface default |
pagination |
Optional paging declaration |
cache_ttl_ms |
Optional response cache |
🔑 risk and approval are the real security surface. What an operation may
do is governed by the grant it runs under and the approval gate — never by how
well documented the vendor is, and never by how the operation is named. Declare
the posture the behaviour actually has: an operation that changes someone else's
state is not read because it happens to be a GET.
#Arguments
| Field | Notes |
|---|---|
key |
The argument name a recipe passes. Dotted prefixes place it: query.*, body.*, header.*, or a bare name for a path variable |
type |
string, number, boolean, object, array |
required |
Whether the call is invalid without it |
affects_target |
Marks the argument that identifies which record is acted on. Governance reads this to describe the blast radius at the approval gate |
A path variable's name is yours, not the vendor's — it is the {{task_id}}
in your own path_template, not the parameter name in their API description.
#Bindings
bind.kind |
Carries |
|---|---|
rest |
method, path_template, optional static_query, static_headers, static_body |
graphql |
The document and variable mapping |
cli_invocation |
An argument vector for a local command — never an interpolated shell string |
core.records |
An action and entity against the pack's own local rows |
⭐ static_query is the sparse-fieldset lever. If your vendor only returns
fields you ask for, every field path your pack later reads has to be requested
here. Otherwise it resolves to nothing at run time, quietly, and you will read
that as "the vendor returned nothing" rather than "I never asked."
For an API that can return integers beyond JavaScript's exact range, declare
bind.response_json: { "unsafe_integers": "string" } so an identity is never
silently rounded; oversized integers then arrive as exact decimal strings while
everything else keeps its JSON type.
#Connections
An ingredient names a connection. That record holds the credential
AEAD-encrypted on the user's own server — never in your pack, never in
Recued's cloud, never in the manifest you publish.
- Riding an existing vendor — name it by slug and the user picks an enrolled connection at install.
- Bringing a new vendor — the user enrols it through the generic connection form: base URL and auth. Recued's setup guide fills that gap: it points at the provider's developer page, identifies the auth type, and suggests values for the individual fields, so an author bringing an unknown vendor is not handing users a blank form.
#Pre-filling the form
You know your vendor's base URL and where its tokens are issued. Say so, and the form opens with those boxes already filled:
{
"connection_hints": [
{
"connection": "acme",
"values": {
"config.base_url": "https://api.acme.example",
"auth.scope": "tasks.read tasks.write"
},
"setup_url": "https://acme.example/developers/tokens"
}
]
}connection_hints sits beside contents on the manifest and is open to every
publisher. connection names a connection one of your ingredients uses.
A hint sets a value, and only on a field the user can see and edit. It cannot hide a field, lock one, or reveal one — those belong to the connection form itself, not to your pack — so a value you get wrong is something the user reads and corrects rather than something that happens to them. A pre-filled box is labelled with your publisher handle until the user edits it.
| Field key | Pre-fills |
|---|---|
config.base_url |
The API endpoint |
subresource_path |
The sub-resource permission boundary |
auth.scope, auth.scopes |
Requested scopes |
auth.token_endpoint, auth.authorize_url |
OAuth endpoints |
auth.param_name |
The query-parameter name for key auth |
A key on that list can still do nothing, silently. Publishing checks the key
is spellable, not that the connection you are hinting actually shows it. A hint
is dropped when the form has no such field, and when the form pins the field
(a registered vendor may fix its own endpoint, and a hint may not move it). The
OAuth keys — auth.scope, auth.scopes, auth.token_endpoint,
auth.authorize_url — only appear once the user picks a matching OAuth auth
type, so hinting them does nothing for a user who signs in with an API key.
Nothing warns you, at publish or at install: hint the keys your connection
genuinely uses, and check the form once with a real install.
Nothing else is accepted, and a key outside that list fails your publish
rather than being quietly dropped. Endpoint values must be a public https URL
carrying no credentials — http, localhost, a private address, or a
user:password@ prefix is refused, and so is a value over 500 characters.
setup_url is held to the same bar, because it is a link you are inviting
someone to follow.
There is no credential here and there cannot be. A hint pre-fills the boxes around a secret; the secret itself is always something the user supplies.
At most eight hint entries per pack. An entry carries connection, values
and setup_url and nothing else — the connection_requirements cells
(api_base, vendor, authority, identity_endpoint, auth, per_org) are
refused inside a hint rather than ignored, so a declaration you thought was
accepted never silently does nothing.
When the user sees it. The pre-fill lands after your pack is installed, not on the install screen: install → the pack's Connections row → Set up → → the form, filled and labelled with your handle. The install screen names the connection your pack wants, so the user knows that before granting anything, but it does not open the form — nothing is installed yet at that point, so there is no pack to read hints from. Write your setup instructions in that order.
#What third-party publishers can and cannot do
You can author a full composition, bring a vendor Recued has never heard of,
ship without any API description, declare synced Sources, pre-fill the connection
form with connection_hints, and publish and install. This path is exercised end
to end.
You cannot declare connection_requirements. That descriptor is not a
richer hint — it decides which of the user's existing connections your pack is
offered for adoption, tags the connection as a particular vendor, names an OAuth
issuer, and supplies the endpoint used to deduplicate accounts. Each of those
acts without the user reading a form, which is why it is reserved to Recued's own
publisher and wired into first-party setup code.
The practical difference is one click. A first-party pack can offer Connect account on the install screen; yours asks the user to enrol through the connection form. With hints and the setup guide that form arrives mostly filled in, but it is still a form, and it is honest to say so rather than imply parity.
You cannot write trust_summary. Verification is computed by Recued, not
asserted by the publisher — a self-published pack cannot mark itself verified,
which is the point.
⏭ These two are related, and the direction is worth knowing while you build: publisher verification is what a streamlined enrolment should eventually be earned by, rather than being permanently first-party. That work is not built and this page will change when it is.
#What publishing checks, and what it does not
| Publish | Install | |
|---|---|---|
Composition shape — missing tables, unknown kind, an operation naming no ingredient |
rejected | rejected |
| An API description the catalog does not pin | rejected | rejected |
| Shipping no API description at all | fine | fine |
| A field path naming something the API never returns | not checked | not checked |
That last row is deliberate and worth understanding. Nothing machine-checks a field path: proving an operation proves its method and route and nothing about the fields inside a response. A gate built on it would go green while proving nothing that matters, and "it passed" would then read as "it was verified" — a check that looks like assurance and is not is worse than no check at all.
The answer is visibility instead: the runtime reports a declared field that has never carried a value on any record, which is exactly what a wrong path looks like, and it treats first- and third-party packs identically.
⇒ Your own declaration is the control, not the gate. Prove your paths against the operation as you shipped it, not against a convenient hand-made request.
#Two failure modes worth naming
A never-populated field may be a wrong path — or your own filter. Before blaming the declaration, re-read the list operation that feeds it. A field projected from a closed record is structurally dead if the query excludes closed records: the path was right and the scope was wrong.
A list that returns identity only must be hydrated. If the list gives back
{ id, url } and every real field arrives on the individual read, then measuring
the list alone reports every field as never-seen. That is a shape artefact, not
a broken declaration.
#Related
- Authoring ingredient packs — the editor path, and the one to start with.
- Pack manifest — the envelope this sits inside.
- Publishing — submission, validation, review.
- Recipe schema — what calls these operations.