A pack is a box that holds recipes and ingredients. A composition is the capability half of that box. It names one surface Recued can talk to. And it lists the operations a recipe may call on that surface.
The Pack editor makes a composition for you. Most authors never read this page. It is here for two reasons. You may want to generate a pack with a tool instead of typing it. The tool could read an API description. It could be a script. It could be a program of your own. Publishing a pack you did not write by hand is a supported path.
Everything below is exactly what publish and install check.
#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 be the handle you are signed in as when you publish. The
pack manifest page explains the rest of the outer part
(the envelope).
#The composition
{
"schema_version": 1,
"slug": "acme-catalog",
"catalog_kind": "official",
"ingredients": [],
"operations": []
}| Field | Notes |
|---|---|
schema_version |
1 |
slug |
Required. The name of the catalog |
catalog_kind |
official, unofficial_acknowledged, or private_byo. This says how official your description of the vendor is |
ingredients |
Exactly one row. See below |
operations |
At least one row |
One composition describes one surface. So ingredients has one entry only.
Does a vendor have two API versions that do not work together? Then make two
packs. The second pack depends on the first one. It does not add 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 |
Each operation's ingredient field points at this |
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 holds the secret keys |
http.result_path |
The default place where records sit in a response. An operation may set its own |
entities |
You can skip it. Vendor entity schemas, for a pack that maps records into a standard shape |
⚠ Keep base origin-only. Say the base ends with a version prefix. And
say an operation path starts with /. Then the prefix gets dropped. That is
just how web addresses join together. Both forms work today. But origin-only
is the rule everyone follows. It never surprises anyone.
A kind: "storage" ingredient is different. It is a pack with its own rows
on your server, not a remote API. See
Authoring ingredient packs for what
that can and cannot do.
#A program a cli ingredient runs
A kind: "cli" ingredient runs a program on the server. Recued does not
install the program. The owner does. The cli block says which program it
is, and how the owner gets it:
{
"slug": "opencode",
"kind": "cli",
"cli": {
"tool": "opencode",
"probe": ["opencode", "--version"],
"package_ref": "system_binary:opencode",
"entry_point": "opencode",
"version": {
"tested": { "from": "1.18.35", "before": "2.0.0" },
"outside": "refuse"
},
"install": [
{ "manager": "npm", "package": "opencode-ai" },
{ "manager": "brew", "package": "anomalyco/tap/opencode" },
{ "docs": "https://opencode.ai/docs" }
]
}
}| Field | Notes |
|---|---|
cli.tool |
The program's name |
cli.probe |
The command that checks the program. Its first word is the file the action starts |
cli.package_ref |
system_binary:<name> means the owner installs the program |
cli.entry_point |
The file to start, when it is not tool |
cli.version |
You can skip it. The versions you tested, and what happens outside them |
cli.install |
You can skip it. The ways the owner can install the program |
#Tested versions
version.tested.from is the oldest version you tested. It counts.
version.tested.before is the first version you did not test. You can skip
it. A version is one to four whole numbers joined by dots. from must be
below before.
version.outside is refuse or warn. It says what happens when the
owner's version is not tested, or cannot be read. Refuse stops the action.
Warn lets it run, and its log says so. This is only the pack's default. The
owner can choose for each program on the pack's page.
With version, probe may only ask for the version. It is the program,
then one of --version, -version, -v, -V or version. The
server runs it by itself, outside any grant. So it must not do anything else.
The cli block is closed. A key it does not know is refused, so a typo
cannot slip through. An older server installs the pack and checks no version.
#Install commands
Each install entry is { manager, package, systems } or { docs }:
managerisbrew,winget,scoop,apt,dnf,npm,pip,pipx,cargoorgo.packageis letters, digits and@ / . _ + -. It starts with a letter, a digit or@. It is at most 214 characters.systemsis any ofmacos,linuxandwindows, each once. You can skip it. Then the entry is for every system.docsis anhttpslink, with no user name or password.
Recued shows the command, like npm install -g opencode-ai@1. It never runs
it. Each command shows only on systems where its manager runs. When the
tested versions end at a major version, the command asks for that major,
where the manager can.
#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"
}
}
]Look at the two examples. The read takes a filter and no target. The write takes the id of the one record it changes. And it says so.
#Operation fields
| Field | Notes |
|---|---|
op |
The name a recipe calls. Once installed it is publisher.pack.op |
ingredient |
The ingredient slug above |
risk |
read, write, admin, or destructive |
approval |
never, ask, or always |
idempotency |
safe, idempotent, or non_idempotent |
description |
What an AI model reads to decide whether to pick this |
args |
The inputs a caller can pass. See below |
bind |
How the call is made. See below |
result_path |
Where records sit in this operation's response. This beats the surface default |
pagination |
You can skip it. Says how paging works |
cache_ttl_ms |
You can skip it. How long to keep a cached response |
🔑 risk and approval are the real safety controls. What an operation
may do is decided by two things. One is the grant it runs under. The other is
the approval gate. It is never decided by how well the vendor is documented.
It is never decided by how the operation is named. Declare what the operation really
does. An operation that changes someone else's data is not read. That is
true even if it happens to be a GET.
#Arguments
| Field | Notes |
|---|---|
key |
The name a recipe uses to pass it. A dotted start says where it goes: query.*, body.*, or header.*. A bare name is a path variable |
type |
string, number, boolean, object, array |
required |
True means the call cannot run without it |
affects_target |
Marks the argument that says which record is touched. The approval gate uses it to explain how much could change |
The name of a path variable is yours to choose. It is not the vendor's
name. It is the {{task_id}} in your own path_template. It is not the name
in the vendor's API description.
#Bindings
bind.kind |
Carries |
|---|---|
rest |
method, path_template, optional static_query, static_headers, static_body |
graphql |
The query text and the variable mapping |
cli_invocation |
A list of arguments for a local command. Never one shell string with values pasted in |
core.records |
An action and an entity on the pack's own local rows |
⭐ static_query is how you ask for fields. Some vendors only return the
fields you ask for. If yours does, every field your pack reads later must be
asked for here. Otherwise that field comes back empty at run time. It fails
quietly. You will think "the vendor sent nothing." The truth is "I never
asked."
Some APIs return whole numbers too big for JavaScript to hold exactly. For
those, declare bind.response_json: { "unsafe_integers": "string" }. Then an
id is never rounded by mistake. Big numbers arrive as exact text. Everything
else keeps its normal JSON type.
#How long a local command may run
Every cli_invocation op is awaited. Recued starts it, waits for it to
finish, and gets its real result. There is no "start it and hand back a
receipt" mode for normal work. The timeout_ms setting says how long that
wait may last. It has three states:
timeout_ms |
Meaning |
|---|---|
| omitted | the catalog's normal deadline |
| a positive number | a hard time limit of that many milliseconds |
0 |
unbounded: no time limit at all |
⭐ 0 is not a trick. It is the honest choice for work whose length you
truly cannot guess. A transcode (turning a media file into another
format), an AI agent task, or a large download. Authors used to write a
progress declaration that declared no progress at all. That removed the
deadline as a side effect. That spelling is gone. Say 0 and mean it.
#No time limit needs progress
⛔ Unbounded requires progress. An op with no deadline AND no progress
declaration has no limit at all. It runs until someone notices. Publishing
one is refused. Can you truly not watch its progress? Then declare a long
but fixed timeout instead.
{
"op": "media.download",
"timeout_ms": 0,
"bind": {
"kind": "cli_invocation",
"argv_template": ["yt-dlp", "-o", "{out}", "{url}"],
"progress": {
"contract": "heartbeat",
"adapter": "yt-dlp-progress",
"stall_ms": 90000
}
}
}#Declaring progress
progress tells Recued how to see that a command is still working. A stuck
command gets killed. A slow but working one is left alone.
| Field | Meaning |
|---|---|
contract |
heartbeat (the tool prints progress) or file-growth (an output file gets bigger) |
adapter |
heartbeat only. Names the code on the host that can read this tool's progress |
watch_path |
file-growth only. The file to watch |
stall_ms |
How long with no progress before the run is killed. Required |
first_output_ms |
heartbeat only, optional. How long the tool may print nothing at all after it starts before the run is stopped |
⛔ adapter is a fixed list, not any text you like. A heartbeat needs
code that understands the tool's output. You cannot turn random output into
progress just by naming it. Does your tool have no adapter? Use file-growth
if it writes its output bit by bit. Or use a fixed timeout_ms.
⚠ stall_ms must be less than a positive timeout_ms. A stall limit
longer than the deadline can never fire. With timeout_ms: 0 there is no
deadline at all. Then stall_ms is the only limit.
⚠ first_output_ms must be less than stall_ms. Any output ends this
window, not only progress. Use it for a tool that prints at once when it works
and prints nothing when it is stuck at the start.
#Long-running services
detached is for services. A service is a program that stays running,
like a tunnel or a local AI model server. Using detached is not a way to
make a normal command return early.
Every detached op must declare supervision. And supervision must declare
readiness:
{
"kind": "cli_invocation",
"argv_template": ["mytool", "serve"],
"stdin_handling": "none",
"shape": "text",
"exit_code_handling": "zero_is_success",
"detached": {
"mode": "runtime_managed",
"completion": { "kind": "marker_file", "exit_pattern": "{dir}/{key}.exit.{code}" },
"cancel": { "kind": "process_group", "pid_pattern": "{dir}/{key}.pid" },
"supervision": {
"restart_policy": "on-crash",
"readiness": { "kind": "http_ok", "url": "http://127.0.0.1:9099/ready" },
"ready_timeout_ms": 30000
}
}
}⛔ A running process does not mean ready. A freshly started service can
crash seconds later. So "the pid exists" is a wrong answer, not just a weak
one. Readiness kinds are a fixed list. It is the same list the service
checkers use: http_ok, tcp_open, pid_file, file_exists, exec_ok,
binary_in_path. Publishing supervision without one is refused.
⚠ A detached op cannot declare output_capture or input_materialize. A
service reports that it is ready. It does not return a file.
#Large outputs
A command that makes a big file returns a reference, not the bytes.
Declare output_capture. Then the file is streamed into the user's file
store. The recipe and the AI model both get a handle to it. The webclient
shows it as a link, or as a picture when it is an image. Nothing big ever
goes through the value channel. So there is no size limit for you to
declare.
#Connections
An ingredient names a connection. A connection is a saved way to talk to an
outside service. That record holds the secret key, locked with strong (AEAD)
encryption on the user's own server. The key is never in your pack. It is
never in Recued's cloud. It is never in the manifest you publish.
- Using a vendor Recued already knows: name it by slug. At install, the user picks one of their existing connections.
- Bringing a new vendor: the user adds it through the general connection form. That form asks for a base URL and the auth details. Recued's setup guide fills the gap. It points at the vendor's developer page. It works out the auth type. It suggests values for each field. So an author who brings an unknown vendor does not hand users a blank form.
#Pre-filling the form
You know your vendor's base URL. You know where its tokens come from. Say so. Then the form opens with those boxes already filled in:
{
"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 next to contents in the manifest. Every
publisher may use it. connection names a connection that one of your
ingredients uses.
A hint sets a value. It can only set a field the user can see and edit. It cannot hide a field. It cannot lock one. It cannot show a hidden one. Those powers belong to the connection form, not to your pack. So if you get a value wrong, the user sees it and fixes it. It never happens to them behind their back. A pre-filled box shows your publisher handle as a label, until the user edits it.
#What a hint can set
| Field key | Pre-fills |
|---|---|
config.base_url |
The API address |
subresource_path |
The sub-resource permission boundary |
auth.scope, auth.scopes |
The scopes you ask for |
auth.token_endpoint, auth.authorize_url |
The OAuth addresses |
auth.param_name |
The query-parameter name for key auth |
A key on that list can still do nothing, quietly. Publishing only checks
that the key is spelled right. It does not check that your connection's form
really shows that field. A hint is dropped in two cases. One: the form has no
such field. Two: the form pins the field. (A registered vendor may fix its
own endpoint. A hint may not move it.) The OAuth keys only appear after the
user picks a matching OAuth auth type. Those keys are auth.scope,
auth.scopes, auth.token_endpoint, and auth.authorize_url. So hinting
them does nothing for a user who signs in with an API key. Nothing warns you
about any of this, at publish or at install. Hint only the keys your
connection really uses. Then check the form once with a real install.
#What a hint may not do
Nothing else is accepted. A key that is not on that list fails your
publish. It is not quietly dropped. An endpoint value must be a public
https URL with no secret in it. These are refused: http, localhost, a
private address, and a user:password@ prefix. A value over 500 characters
is refused too. setup_url follows the same rules. It is a link you are
asking someone to click.
There is no secret key here, and there cannot be one. A hint fills in the boxes around a secret. The secret itself always comes from the user.
A pack may have at most eight hint entries. An entry has connection,
values, and setup_url, and nothing else. The connection_requirements
fields (api_base, vendor, authority, identity_endpoint, auth,
per_org) are refused inside a hint. They are not ignored. So a line you
thought was accepted never quietly does nothing.
#When the pre-fill lands
The pre-fill shows up after your pack is installed. It is not on the install screen. The path is: install → the pack's Connections row → Set up → → the form, filled in and labelled with your handle. The install screen names the connection your pack wants. So the user knows that before they grant anything. But the install screen 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 do
You can write a full composition. You can bring a vendor Recued has never
heard of. You can ship with no API description at all. You can declare
synced Sources. You can pre-fill the connection form with
connection_hints. And you can publish and install. This whole path is
tested end to end.
#What third-party publishers cannot do
You cannot declare connection_requirements. That field is not a bigger
hint. It decides which of the user's existing connections your pack is
offered for. It tags a connection as a certain vendor. It names an OAuth
issuer. It gives the endpoint used to spot duplicate accounts. Each of those
things acts without the user reading a form. That is why only Recued's own
publisher may use it, wired into first-party setup code.
In practice the difference is one click. A first-party pack can show Connect account on the install screen. Your pack asks the user to add the connection through the form. With hints and the setup guide, that form arrives mostly filled in. But it is still a form. It is honest to say so, not to pretend they are the same.
You cannot write trust_summary. Recued works out verification itself.
A publisher cannot claim it. A self-published pack cannot mark itself
verified. That is the whole point.
⏭ These two things are linked. The direction matters while you build. One day, publisher verification should be what earns a smoother way to add a connection (the one-click Connect account). That flow should not stay first-party forever. That work is not built yet. This page will change when it is.
#What publishing checks, and what it does not
| Publish | Install | |
|---|---|---|
The shape of the composition: a missing table, an unknown kind, an operation that names 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 that names something the API never returns | not checked | not checked |
That last row is on purpose. It is worth understanding. No machine checks a field path. Testing an operation proves its method and its route. It proves nothing about the fields inside a response. A check built on that would pass while proving nothing that matters. Then "it passed" would sound like "it was verified" to a reader. A check that looks like proof but is not one is worse than no check at all.
Instead, Recued makes the problem visible. The runtime reports any declared field that has never held a value on any record. That is exactly what a wrong path looks like. First-party and third-party packs are treated the same way.
⇒ Your own declaration is the control, not the gate. Test your paths against the operation as you shipped it. Do not test against a hand-made request that happens to work.
#Two failure modes worth naming
A field that is never filled may be a wrong path. Or it may be your own filter. Before you blame the declaration, re-read the list operation that feeds it. Say a field only exists on closed records. And say your query leaves out closed records. Then that field can never be filled. The path was right. The scope was wrong.
A list that returns only ids must be filled out with reads. Say the list
gives back only { id, url }. And say every real field arrives only when you
read one record. Then measuring the list alone reports every field as never
seen. That is a shape effect, 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.