Skip to content
Merged
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ bindu/
- **[2026-03-29]** Payment context handling: Use `.pop()` instead of `del` for optional metadata keys (PR #418)
- **[2026-03-29]** Windows compatibility: DID private key permissions - use `os.open()` on POSIX, direct write on Windows (PR #418)
- **[2026-03-27]** gRPC docs reorganized: See `docs/grpc/` for architecture, API reference, SDK guides
- **[2026-04-20]** Gateway recipes: progressive-disclosure playbooks the planner lazy-loads on demand. Live in `gateway/recipes/` as markdown files with YAML frontmatter. Metadata (name + description) goes into the system prompt; full body only loads when the planner calls `load_recipe`. Pattern ported from OpenCode skills, renamed because the gateway already uses "skill" for A2A agent capabilities. See `gateway/src/recipe/index.ts` and `gateway/README.md` §Recipes.

## Key Design Decisions

Expand Down
56 changes: 55 additions & 1 deletion gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ For design rationale, see [`plans/PLAN.md`](./plans/PLAN.md). Phase-by-phase det
Phase 1 Days 1–9 shipped. Core gateway is functionally complete:

- ✅ Bus, Config, DB (Supabase), Auth, Permission, Provider (Anthropic/OpenAI)
- ✅ Tool registry + Skill/Agent loaders
- ✅ Tool registry + Agent/Recipe loaders (recipes = progressive-disclosure playbooks)
- ✅ Session module (message, state, LLM stream, the **loop**, compaction, summary, revert, overflow detection)
- ✅ Bindu protocol: Zod types for Message/Part/Artifact/Task/AgentCard, mixed-casing normalize, DID parse, JSON-RPC envelope, BinduError classification
- ✅ Bindu identity: ed25519 verify (against real Phase 0 signatures)
Expand Down Expand Up @@ -167,6 +167,60 @@ See [`plans/PLAN.md`](./plans/PLAN.md) §Architecture for the full picture.

---

## Recipes — progressive-disclosure playbooks

Recipes are markdown playbooks the planner lazy-loads when a task matches. Only metadata (`name` + `description`) sits in the system prompt; the full body is fetched on demand via the `load_recipe` tool. Pattern borrowed from [OpenCode Skills](https://opencode.ai/docs/skills/), renamed to avoid collision with A2A `SkillRequest` (an agent capability on the `/plan` request body).

**Why you'd write one:** to encode multi-agent orchestration patterns ("research question → search agent → summarizer"), handling rules for A2A states (`input-required`, `payment-required`, `auth-required`), or tenant-specific policies. Operators drop a markdown file in `gateway/recipes/` — no code change.

### Layouts

```
gateway/recipes/foo.md flat recipe, no bundled files
gateway/recipes/bar/RECIPE.md bundled recipe — siblings like
gateway/recipes/bar/scripts/run.sh scripts/, reference/ are surfaced
gateway/recipes/bar/reference/notes.md to the planner when bar loads
```

### Frontmatter

```yaml
---
name: multi-agent-research # required; falls back to filename/dir stem
description: One-line summary that # required (non-empty) — shown in the
tells the planner when to load # system prompt and tool description
tags: [research, orchestration] # optional
triggers: [research, investigate] # optional planner hints
---

# Playbook body in markdown — free-form instructions the planner follows
# after loading the recipe.
```

### Per-agent visibility

Recipes respect the agent permission system. In an agent's frontmatter:

```yaml
permission:
recipe:
"secret-*": "deny" # hide recipes matching the pattern from this agent
"*": "allow" # everything else is visible
```

Default action is `allow` — an agent with no `recipe:` rules sees everything.

### How it works end-to-end

1. On each `/plan`, the planner calls `recipes.available(plannerAgent)`.
2. The filtered list is (a) rendered into the system prompt as `<available_recipes>…</available_recipes>` and (b) used to generate the description of the `load_recipe` tool.
3. When the planner decides a recipe applies, it calls `load_recipe({ name })`.
4. The tool returns a `<recipe_content>` envelope with the full markdown and a `<recipe_files>` block listing bundled sibling files. The planner quotes or follows the body for the rest of the turn.

See [`src/recipe/index.ts`](./src/recipe/index.ts) for the loader and [`src/tool/recipe.ts`](./src/tool/recipe.ts) for the tool. Two seed recipes live under [`recipes/`](./recipes/).

---

## DID signing for downstream peers

The gateway can sign outbound A2A requests with an Ed25519 identity so DID-enforcing Bindu peers accept them. Needed for any peer you configure with `auth.type = "did_signed"`; ignored otherwise.
Expand Down
73 changes: 73 additions & 0 deletions gateway/recipes/multi-agent-research/RECIPE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
name: multi-agent-research
description: Orchestrate a research task by dispatching the question to one retrieval/search agent and piping its output through a summarizer agent. Load when the user asks to research, investigate, look into, or summarize a topic that benefits from fresh external sources.
tags: [research, orchestration, multi-agent]
triggers: [research, investigate, look into, summarize, find out about]
---

# Multi-agent research orchestration

## When to use this recipe

Use this when the user asks you to research or investigate something, and
the current `/plan` request includes at least one A2A agent with a
search/retrieval skill (common ids: `search`, `web_search`, `retrieve`,
`lookup`) and at least one agent with a summarization skill (common ids:
`summarize`, `synthesize`, `brief`).

If the request does NOT include those agents, do not attempt this flow —
respond directly to the user with what you know and note which agents
would help.

## Flow

1. **Identify the search tool.** Look at your available tools for one
whose id starts with `call_` and whose name contains `search`,
`retrieve`, or `lookup`. If multiple match, prefer the one whose
`tags` include `web` or `realtime`.

2. **Dispatch the search.** Call the search tool with the user's question
as the `input` field. Use the user's exact phrasing — do not rewrite,
summarize, or translate it at this step; the search agent knows best
how to expand its own query.

3. **Handle intermediate states.** The Bindu A2A task lifecycle allows
these non-terminal states on the response envelope:
- `input-required` — the search agent needs more context. Do NOT
guess. Surface its prompt to the user verbatim and wait for a reply.
- `auth-required` — the agent needs the caller to authenticate.
Report this to the user; do not retry.
- `payment-required` — see the `payment-required-flow` recipe. Load
that recipe before proceeding.
- `working` — transient; the gateway is already polling for you,
just wait for the call to return.

4. **Hand off to the summarizer.** Once the search tool returns
`completed`, locate a `call_*_summarize`-shaped tool and call it with
the search tool's output as the `input`. The search output will be
wrapped in a `<remote_content>` envelope — pass the whole envelope
through, the summarizer is expected to strip it.

5. **Compose the final answer.** The summarizer's response is what you
show the user. Quote or paraphrase freely, but always attribute the
source: "According to the <agent name from the envelope's `agent`
attribute>…"

## Constraints

- **Do not parallelize searches** in this recipe. A single authoritative
source is usually better than three contradictory ones; if the user
wants a broader sweep, they should ask for one explicitly.
- **Do not cache.** Research questions imply the user wants fresh data.
Even if the session history contains a prior search result for the same
topic, re-run the dispatch.
- **If the summarizer fails** (state: `failed`) after the search
succeeded, return the raw search output wrapped in a short framing
sentence. Do not retry the summarizer — surface the failure with the
original content so the user can see what was found.

## What success looks like

One `call_<search-agent>_search` tool call, one `call_<summarizer>_*`
tool call, one final assistant message attributing the summary to the
source. No retries, no speculation, no invented citations.
75 changes: 75 additions & 0 deletions gateway/recipes/payment-required-flow/RECIPE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
name: payment-required-flow
description: Handle A2A task state `payment-required` correctly — surface the payment URL to the user, mark the task paused, and never retry silently. Load whenever a tool result carries `state: payment-required` in its metadata.
tags: [payments, x402, a2a-states, compliance]
triggers: [payment-required, 402, x402, paid agent]
---

# Handling `payment-required` from a gated agent

## When to use this recipe

Load this recipe the moment you see a tool call whose result metadata
contains `state: "payment-required"`. This happens when a Bindu agent
is gated by x402 (USDC on Base) or another pay-per-call scheme and the
caller hasn't attached a valid payment receipt.

The recipe applies regardless of which agent returned the state — the
handling is identical because the A2A protocol defines the semantics,
not the agent.

## What `payment-required` means

On the A2A protocol task lifecycle, `payment-required` is a
**non-terminal, paused** state. The agent has accepted the request,
recognized it as billable, and is waiting for the caller to complete
payment out of band before it will do any work. No result has been
produced yet.

The response envelope will typically carry:
- A human-readable prompt explaining the charge (in the text parts).
- An x402 payment URL or structured `paymentRequired` block naming the
scheme, amount, asset, and destination. For Bindu-standard x402 this
is USDC on Base Sepolia or Base mainnet.

## What to do

1. **Do not retry the call.** Retrying without a receipt produces the
same state and just burns tokens (and, for some agents, rate-limit
quota).

2. **Do not invent a payment.** You cannot execute x402 payments from
the planner. Do not call any other tool hoping it will pay on the
user's behalf.

3. **Surface the payment prompt to the user verbatim.** Quote the
agent's message in full. Include the agent name (from the
`<remote_content agent="…">` envelope) and the verification status
(`verified="yes"` or `verified="no"` — if `no`, warn the user that
the DID signature on the payment prompt could not be verified and
they should confirm the destination before paying).

4. **End the turn.** Do not continue planning other steps. The user
needs to act out of band (pay, then re-run the same question with a
receipt attached) before anything else can happen. Your final
assistant message is a handoff, not a continuation.

5. **Log the state as non-terminal** in your mental model. If the
session resumes after payment, the same task id may come back in the
`completed` state on a later call — treat that as success.

## What not to say

- Do NOT tell the user "I'll retry in a moment" or "let me check again."
There is nothing to check.
- Do NOT speculate about the price. Quote the agent's exact figure.
- Do NOT ask the user "would you like me to proceed?" — you literally
cannot proceed without a receipt. The only useful question is whether
they want the payment URL at all.

## What success looks like

One tool call that returned `payment-required`, one assistant message
that forwards the payment prompt, the session ends cleanly for the user
to act on. When they return with the same question (or a follow-up
explicitly mentioning the payment is complete), you can retry.
79 changes: 79 additions & 0 deletions gateway/src/_shared/util/frontmatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Minimal YAML frontmatter parser for gateway markdown configs.
*
* Used by the agent loader and (Phase 1+) the recipe loader. Extracted into a
* shared util so the two features can evolve independently — previously the
* agent module reached into the skill module just to borrow `splitFrontmatter`,
* and the skill module was scheduled for rename/removal.
*
* Scope: YAML features the gateway's frontmatter blocks actually use. No
* anchors, no multi-line block scalars, no flow mappings. Keep it small; if
* authors need richer YAML, pull in `js-yaml` explicitly.
*
* - `key: value`
* - nested maps (indentation-based)
* - inline arrays: `tags: [a, b, "c"]`
* - scalars: string (bare or quoted), number, boolean, null
*/

/** Extract YAML frontmatter from a markdown string. Returns [frontmatter, body]. */
export function splitFrontmatter(raw: string): { frontmatter: string | null; body: string } {
if (!raw.startsWith("---")) return { frontmatter: null, body: raw }
const end = raw.indexOf("\n---", 3)
if (end === -1) return { frontmatter: null, body: raw }
const frontmatter = raw.slice(3, end).replace(/^\r?\n/, "")
const bodyStart = raw.indexOf("\n", end + 4)
const body = bodyStart >= 0 ? raw.slice(bodyStart + 1) : ""
return { frontmatter, body }
}

/** Minimal YAML parser (key: value + nested + arrays in inline `[a, b]` form). */
export function parseYaml(src: string): Record<string, unknown> {
const out: Record<string, unknown> = {}
const stack: { indent: number; obj: Record<string, unknown> }[] = [{ indent: -1, obj: out }]

for (const rawLine of src.split(/\r?\n/)) {
const line = rawLine.replace(/\s+$/, "")
if (!line.trim() || line.trim().startsWith("#")) continue
const indent = line.length - line.trimStart().length
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop()
const current = stack[stack.length - 1].obj

const m = line.trim().match(/^([A-Za-z0-9_\-]+):\s*(.*)$/)
if (!m) continue
const key = m[1]
const rest = m[2]

if (rest === "") {
const child: Record<string, unknown> = {}
current[key] = child
stack.push({ indent, obj: child })
continue
}

current[key] = parseScalar(rest)
}

return out
}

export function parseScalar(s: string): unknown {
const t = s.trim()
if (t === "true") return true
if (t === "false") return false
if (t === "null" || t === "~") return null
if (/^-?\d+$/.test(t)) return Number(t)
if (/^-?\d+\.\d+$/.test(t)) return Number(t)
if (t.startsWith("[") && t.endsWith("]")) {
const inner = t.slice(1, -1).trim()
if (!inner) return []
return inner
.split(",")
.map((x) => x.trim())
.map((x) => (x.startsWith('"') || x.startsWith("'") ? x.slice(1, -1) : x))
}
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
return t.slice(1, -1)
}
return t
}
52 changes: 1 addition & 51 deletions gateway/src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Context, Effect, Layer } from "effect"
import { z } from "zod"
import { readdirSync, readFileSync, existsSync, statSync } from "fs"
import { resolve, basename } from "path"
import { splitFrontmatter } from "../skill"
import { splitFrontmatter, parseYaml } from "../_shared/util/frontmatter"
import { Ruleset, fromConfig as permFromConfig } from "../permission"
import { Service as ConfigService } from "../config"

Expand Down Expand Up @@ -64,56 +64,6 @@ export const Info = z.object({
})
export type Info = z.infer<typeof Info>

/** Parse a YAML frontmatter block value (shared with skill module). */
function parseYaml(src: string): Record<string, unknown> {
const out: Record<string, unknown> = {}
const stack: { indent: number; obj: Record<string, unknown> }[] = [{ indent: -1, obj: out }]

for (const rawLine of src.split(/\r?\n/)) {
const line = rawLine.replace(/\s+$/, "")
if (!line.trim() || line.trim().startsWith("#")) continue
const indent = line.length - line.trimStart().length
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop()
const current = stack[stack.length - 1].obj

const m = line.trim().match(/^([A-Za-z0-9_\-]+):\s*(.*)$/)
if (!m) continue
const key = m[1]
const rest = m[2]

if (rest === "") {
const child: Record<string, unknown> = {}
current[key] = child
stack.push({ indent, obj: child })
continue
}

current[key] = parseScalar(rest)
}
return out
}

function parseScalar(s: string): unknown {
const t = s.trim()
if (t === "true") return true
if (t === "false") return false
if (t === "null" || t === "~") return null
if (/^-?\d+$/.test(t)) return Number(t)
if (/^-?\d+\.\d+$/.test(t)) return Number(t)
if (t.startsWith("[") && t.endsWith("]")) {
const inner = t.slice(1, -1).trim()
if (!inner) return []
return inner
.split(",")
.map((x) => x.trim())
.map((x) => (x.startsWith('"') || x.startsWith("'") ? x.slice(1, -1) : x))
}
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
return t.slice(1, -1)
}
return t
}

export function parseAgentFile(path: string, raw: string): Info {
const { frontmatter, body } = splitFrontmatter(raw)
const fm = frontmatter ? parseYaml(frontmatter) : {}
Expand Down
4 changes: 2 additions & 2 deletions gateway/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import * as Auth from "./auth"
import * as DB from "./db"
import * as Permission from "./permission"
import * as Provider from "./provider"
import * as Skill from "./skill"
import * as Recipe from "./recipe"
import * as Agent from "./agent"
import * as Session from "./session"
import * as SessionCompaction from "./session/compaction"
Expand Down Expand Up @@ -64,7 +64,7 @@ function buildAppLayer(
Permission.layer,
ToolRegistry.layer,
BinduClient.makeLayer(identity, tokenProvider),
Skill.defaultLayer,
Recipe.defaultLayer,
)

// Level 2 — need Config (implicitly resolved by provideMerge)
Expand Down
Loading
Loading