Authoring a FlurryPORT recipe
A recipe is a small declarative document: what an agent may send (the intent schema), how it becomes a provider call (the transformation and target template), and what the human sets up once (install parameters and secrets). Agents install recipes by copying them, so the document is the product: write every field as if it will be pasted unchanged into a live pipe, because it will be.
The prime rule: your recipe will be copied verbatim
An installing agent pattern-matches from whatever is in front of it. A vague field becomes a guess, a placeholder left in prose becomes a live value, and an undeclared convention simply does not exist. Structured facts beat paragraphs every time: one canonical example teaches more than a page of description.
The authoring loop
- Draft the content document (start from the exemplar below).
- Lint it: the lint_recipe tool, or POST /api/v1/catalog/recipes/lint. Every error is exactly what publishing would refuse.
- Fix errors, act on warnings, lint again until clean.
- Prove it: fire the pipe at the real destination once. Publishing requires that receipt, no exemptions.
- Publish. A moderation review with your functional receipt rides every version.
The content document
Recipes are published as one JSON content document. These are its fields; the exemplar below shows every one of them in a shipped, receipt-proven recipe.
displayName- The human name shown on the catalog page and in agent listings.description- What the recipe does and where the credential lives. If the URL carries the secret, say so here, like the exemplar does.intentSchema- The pipe's input contract: what post_intent callers send. Keep required minimal and honest, and give every optional field a description saying what happens when it is omitted.parameters- Install-time facts the installer inlines as literals. Structured, typed, non-secret. See the parameter facts below.transformation- A JSONata expression mapping the intent ($body.*) plus inlined install values ($install.*) to the provider payload.targetTemplate- The delivery URL, method, and headers. $secrets.NAME references resolve server-side at delivery, never in the agent's context.secrets- Declarations of every credential the recipe needs: logicalName plus walkthrough. Values never appear anywhere in a recipe.tools- Tool definitions an agent may register for this pipe. Names starting with flry_ are reserved to first-party recipes.gotchas- Provider quirks the receipt run taught you: caps, encodings, status codes that lie.
Install parameters are structured facts
$install.NAME means: inline a literal here at install time. The engine has no runtime $install binding, so every placeholder must be a declared parameter the installer can resolve before the pipe goes live. Each entry carries four facts:
type- string, number, or boolean: tells the installing agent whether to inline the value quoted or bare.required- Whether an install can proceed without it.example- One canonical literal. Agents pattern-match what is in front of them.howToFind- The user-facing words the agent relays when asking for the value. Template plumbing never leaks into the human conversation.
Install parameters are non-secret by definition: they are inlined visibly into the transformation. Credential-shaped parameter names are refused at publish. Anything sensitive is $secrets.NAME, declared under secrets.
Secrets: the shape and the custody rule
Each secrets entry is exactly { logicalName, walkthrough }. logicalName is the SCREAMING_SNAKE name that $secrets.NAME references and the secret page labels its box with; other keys are not read. The walkthrough is the service-side steps you actually took proving the recipe: the agent relays them, and the human enters the value on the FlurryPORT secret page. Values never transit the model.
When the destination host varies per user (self-hosted chat servers, per-tenant webhook URLs), the whole URL is the secret: targetTemplate.url is just $secrets.NAME. A hardcoded host makes the recipe single-tenant, and lint cannot know a host is a placeholder; only the receipt gate catches it. Token-in-path, like the exemplar's bot URL, fits when the host is fixed and only the credential varies.
Every lint finding, and what it means
Errors refuse publish, fail closed. Warnings are install-success advice and never block. The lint_recipe tool and the publish gate run the same checks, so nothing here is learn-by-rejection-only.
invalid_content(error) The document must be valid JSON, and a JSON object at the root.reserved_tool_prefix(error) Tool names starting with flry_ belong to first-party recipes. Pick a name under your own prefix.param_invalid(error) Each parameters entry must be an object with name, type, required, example, and howToFind.param_missing_name(error) The name is what $install.NAME references and what the agent asks the user for.credential_shaped_param(error) Install params are inlined visibly, so they are non-secret by definition. Move sensitive values to $secrets.NAME.param_missing_type(error) Declare string, number, or boolean: the quoted-or-bare inlining contract.param_invalid_type(error) Only string, number, and boolean are install-param types.param_missing_required(warning) Say explicitly whether an install can proceed without the value.param_missing_example(warning) One canonical literal beats a paragraph.param_missing_how_to_find(warning) Give the user-facing words for the ask, so plumbing never reaches the human.undeclared_install_param(error) Every $install placeholder must be a declared parameter. An undeclared placeholder is a rule the installer cannot find.unused_parameter(warning) A declared parameter nothing references is probably a leftover.secret_invalid(error) Each secrets entry must be an object with logicalName and walkthrough.secret_missing_logical_name(error) logicalName is the only key the platform reads for a secret's name.secret_name_style(warning) Secret names are SCREAMING_SNAKE by convention, like TELEGRAM_BOT_TOKEN.secret_missing_walkthrough(warning) Write the exact steps to obtain the value; the agent relays them.undeclared_secret_ref(warning) A $secrets.NAME the document references but never declares has no walkthrough to show at setup.unused_secret(warning) A declared secret nothing references is probably a leftover.reserved_secret_name(error) FP_SIGNING_ names belong to platform delivery signing keys and never appear in recipes.docs_invalid(error) docs must be a markdown string. It lives inside the signed content, so a docs edit is a new version like any other change.docs_too_large(error) The docs body caps at 48 KB. Docs are a page body, not a book; link to your own site for anything longer.docs_raw_html(error) No raw HTML in docs prose; the renderer escapes it anyway. HTML samples go in fenced code blocks; links are written [text](https://...).docs_image(error) Image syntax is not rendered in v1; describe it in prose or link to it.docs_link_not_https(error) Only https:// links render in docs. Literal example URLs, like localhost, belong in backticks as inline code.summary_too_long(error) listingSummary is the shelf card: one verb-first sentence, max 160 chars. The longer story belongs in description and the docs body.
The exemplar: telegram-send, field by field
This is the shipped flurryport:telegram-send content document, receipt-proven at its real destination. It lints clean against every rule on this page. Start your draft from this shape.
{
"displayName": "Send a Telegram message",
"description": "Deliver a message via a Telegram bot to a chat. The bot token rides the request URL path, which is exactly why it must never transit the model - FlurryPORT resolves $secrets in the URL server-side at delivery.",
"intentSchema": {
"type": "object",
"required": ["text"],
"properties": {
"text": { "type": "string", "maxLength": 4096 },
"chatId": { "type": "string", "description": "Override the installed default chat." }
}
},
"parameters": [
{
"name": "chatId",
"binding": "install-time",
"type": "string",
"required": true,
"example": "8123456789",
"howToFind": "Send your bot a message, then open your bot's getUpdates page in a browser; the chat id is the numeric message.chat.id value. For a group, add the bot to the group first; group ids are negative numbers.",
"description": "Default chat id for this install; runtime intent.chatId overrides."
}
],
"transformation": "{ \"chat_id\": $body.chatId ? $body.chatId : $install.chatId, \"text\": $body.text }",
"targetTemplate": {
"url": "https://api.telegram.org/bot$secrets.TELEGRAM_BOT_TOKEN/sendMessage",
"method": "POST",
"headers": { "Content-Type": "application/json" }
},
"secrets": [
{
"logicalName": "TELEGRAM_BOT_TOKEN",
"walkthrough": "Message @BotFather, run /newbot, copy the token. Start a chat with your bot (or add it to a group) and get the chat id from getUpdates. Paste the token in the FlurryPORT secret page."
}
],
"tools": [
{
"name": "flry_telegram_send",
"description": "Send a message via the configured Telegram bot. Input: text (required), chatId (optional override).",
"inputSchema": {
"type": "object",
"required": ["text"],
"properties": { "text": { "type": "string" }, "chatId": { "type": "string" } }
}
}
],
"gotchas": "Token-in-path means URL logs are sensitive on the Telegram side; text over 4096 chars is rejected by the API."
}description- Says where the credential lives and why it never transits the model. An installing agent repeats this reasoning to its user.intentSchema- required is just ["text"]: minimal and honest. The optional chatId says what it does when present.parameters[chatId]- All four facts present. The howToFind is what the agent says to the human, word for word; the example is one canonical literal.transformation- Intent fields resolve via $body.*; the install literal was inlined as $install.chatId at install time. The ternary makes the runtime override explicit.targetTemplate.url- Token-in-path: the host is fixed, only the credential varies, so $secrets.TELEGRAM_BOT_TOKEN rides the path and resolves server-side. If your host varies per user, make the whole URL the secret instead.secrets[TELEGRAM_BOT_TOKEN]- The shape is exactly logicalName plus walkthrough. The walkthrough is the steps the receipt run actually took.tools[flry_telegram_send]- First-party recipes may use the flry_ prefix; community recipes pick their own. The input schema mirrors intentSchema.gotchas- Provider truths the receipt run taught: write yours down, the next installer needs them.
A proven pipe gets published
The receipt gate has no exemptions: a delivery recipe publishes with a receipt from firing this exact content at its real destination, first-party included. Write the walkthrough from that run; anything you did not need, the user does not either. Every published version carries a moderation review with the functional receipt attached, and human approval stays in the loop.
Lint from anywhere
Agents: run npx -y flurryport mcp (no account needed) and call lint_recipe with your draft. Anything else: POST the document as {"ContentJson": "..."} to /api/v1/catalog/recipes/lint. Same checks, structured findings, nothing stored.
Securing an endpoint
The capture URL is a write capability, reading always needs a token, and signed intake can close an inbox to everyone but trusted writers. The full model, including key custody and what your recipe should declare about integrity, lives at /recipes/security. Intake recipes whose value depends on message integrity should teach it in their own content.