Reference

Composition

The ingredients and operations tables inside a pack, with bindings, risk and approval, arguments, connections, and what publishing does and does not check.

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

json
{
  "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

json
{
  "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

json
{
  "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:

json
{
  "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 }:

  • manager is brew, winget, scoop, apt, dnf, npm, pip, pipx, cargo or go.
  • package is letters, digits and @ / . _ + -. It starts with a letter, a digit or @. It is at most 214 characters.
  • systems is any of macos, linux and windows, each once. You can skip it. Then the entry is for every system.
  • docs is an https link, 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

json
[
  {
    "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.

json
{
  "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:

json
{
  "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:

json
{
  "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.

Recued Docs

Search documentation

Start typing to search the documentation.