BakedBrie docs

Recipe: connect any API

A custom API destination sends a finished card to any service with an HTTPS API. You describe the calls as steps, and BakedBrie makes them with your key. Use it when the service has no preset (Slack and WoopSocial have their own recipes). If the service signs in with OAuth instead of a key, read this page first, then Connect any API with OAuth.

How it works:

  • The destination holds the definition: a base URL, how it signs in, and named actions made of steps. The deliver action runs when a card on the board finishes (or is approved, on a board with reviews).
  • The key is sent only to the base URL's host, in the header you name. It never appears in the definition, the chat, a receipt or a saved answer.
  • preview shows each request exactly as it would be sent, with the key masked. Nothing is sent.
  • test_call checks the connection with one GET you name, sent once.
  • A step that posts is sent once. If its answer never arrives, BakedBrie does not send it again (see Unclear sends).

Every step field, template and filter is in Concepts: custom API steps and templates.

Before you run it

  1. Connect Claude Code or Codex to BakedBrie. whoami must show destinations and api_workflow on.
  2. Put the service's API key in an environment variable in your own shell, for example export ACME_API_KEY=.... Never paste a key into the chat.
  3. Have these ready:
    • the API's base URL (https), for example https://api.example.com/v1;
    • the header the key goes in and the text before it (most services: Authorization and Bearer );
    • a harmless GET path that proves the key works, for example /me;
    • in plain words, what a finished card should become at the service.
  4. Fill in the UPPERCASE {{...}} values in the prompt. Lowercase ones like {{card.title}} are BakedBrie templates: keep them as written.

The prompt

In BakedBrie, make finished cards on the board "{{BOARD_NAME}}" send to {{SERVICE_NAME}} through a custom API destination. Use only the BakedBrie MCP tools. Start with whoami and stop if destinations or api_workflow is off. First read the docs pages /docs/recipes/connect-any-api and /docs/concepts (section "Custom API steps and templates") with read_docs.

The API: base URL {{BASE_URL}}. The key is in my environment variable {{ENV_VAR}} and goes in the header {{HEADER}} as "{{PREFIX}}<key>". A harmless GET that proves the key works: {{CHECK_PATH}}.
What a finished card should become: {{WHAT_DELIVER_DOES}}

1. Find the board with list_boards.
2. Call prepare_secret with kind api_key, purpose destination and env_var {{ENV_VAR}}. Run the command it returns in my shell exactly as given, then use the drop_id. Never print the key.
3. Write the webhook definition from the recipe's patterns. Call manage_destination with action preview and that webhook (a draft), show me each request, and fix every problem it lists. A preview sends nothing.
4. When I agree, create it with manage_destination: action create, name "{{SERVICE_NAME}}", the webhook, credential {"drop_id": "<the drop id>"}.
5. Check the connection: manage_destination action test_call with the destination_id and path {{CHECK_PATH}}. Read it with test_call_id every few seconds until state is done or failed, and tell me the status.
6. Make it the board's default destination with manage_destination action set_board_default.
7. Read it back with manage_destination action get and tell me, in plain words, what each finished card will send.

Worked example: httpbin

httpbin.org is a public test service: https://httpbin.org/anything answers every request with everything it received, including every header. That makes it a safe first try, with a made-up key, because you can see exactly what BakedBrie sent. BakedBrie removes the key from what it keeps: receipts, saved answers and the check's snippet show [removed] in its place.

  1. In your shell: export HTTPBIN_DEMO_KEY=made-up-key-123
  2. prepare_secret:
   {"kind": "api_key", "purpose": "destination", "env_var": "HTTPBIN_DEMO_KEY"}

Run the command it returns, exactly as given. Keep the drop_id.

  1. manage_destination with action preview and the draft definition. Nothing is sent:
   {
     "action": "preview",
     "webhook": {
       "base_url": "https://httpbin.org/anything",
       "auth": {"header": "Authorization", "prefix": "Bearer "},
       "actions": {
         "deliver": {
           "steps": [
             {
               "name": "post",
               "path": "/posts",
               "body": {"title": "{{card.title}}", "text": "{{result.markdown | clip:2000}}", "link": "{{card.web_url}}"},
               "save": {"title": "$.json.title", "url": "$.url"},
               "receipt": ["title", "url"],
               "ok_when": {"path": "$.method", "equals": "POST"}
             }
           ]
         }
       }
     }
   }

The answer lists one request: POST https://httpbin.org/anything/posts, the header authorization: Bearer •••• (saved key), and the JSON body filled in from a sample card. problems is empty.

  1. manage_destination with action create, name "httpbin test", the same webhook, and credential {"drop_id": "<the drop id>"}. Keep the destination id.
  2. manage_destination with action test_call, the destination_id and path /me. It answers {"test_call_id": "...", "state": "pending"}. A few seconds later, call it again with test_call_id: state is done, status is 200, and the snippet shows httpbin's echo of the headers with "Authorization": "Bearer [removed]".
  3. manage_destination with action set_board_default, the board_id and the destination_id.
  4. Move one card to Done (preview_move, then move_card). list_results then shows its delivery with a receipt like this:
   {"action": "deliver", "state": "succeeded", "steps": [{"name": "post", "state": "succeeded", "http_status": 200, "error_code": null, "ids": {"title": "Spring launch carousel", "url": "https://httpbin.org/anything/posts"}}]}
  1. When you are done, turn it off: manage_destination with action disable and the destination_id.

Patterns

Each pattern is a complete definition for a made-up API at https://api.example.com/v1: POST /media takes one file and answers {"id"}, POST /posts takes JSON and answers {"id", "url"}, GET /posts?text=... lists posts. Change the paths and fields to match your service's own API docs, then preview.

Send JSON

One call per finished card. require says the answer must carry an id; external_id puts it on the receipt as the delivery's id.

{
  "base_url": "https://api.example.com/v1",
  "auth": {"header": "Authorization", "prefix": "Bearer "},
  "actions": {
    "deliver": {
      "steps": [
        {
          "name": "post",
          "path": "/posts",
          "body": {"title": "{{card.title}}", "text": "{{result.markdown}}", "link": "{{card.web_url}}"},
          "save": {"id": "$.id", "url": "$.url"},
          "require": ["id"],
          "receipt": ["id", "url"],
          "external_id": "id"
        }
      ]
    }
  }
}

Upload files, then create a post

The first step runs once per file the card produced (for_each: files) and saves each file's id. The second step sends the list of ids with {{steps.upload | optional | map:id}}: map:id takes the id of each saved answer, and optional leaves media_ids out when the card has no files. An upload that runs twice leaves an unused file at the service, not a second post, so it is marked retry_safe.

{
  "base_url": "https://api.example.com/v1",
  "auth": {"header": "Authorization", "prefix": "Bearer "},
  "actions": {
    "deliver": {
      "steps": [
        {
          "name": "upload",
          "path": "/media",
          "body_format": "multipart_file",
          "file_field": "file",
          "for_each": "files",
          "save": {"id": "$.id"},
          "require": ["id"],
          "receipt": ["id"],
          "retry_safe": true
        },
        {
          "name": "post",
          "path": "/posts",
          "body": {"text": "{{card.title}}: {{result.captions.caption}}", "media_ids": "{{steps.upload | optional | map:id}}"},
          "save": {"id": "$.id", "url": "$.url"},
          "require": ["id"],
          "receipt": ["id", "url"],
          "external_id": "id"
        }
      ]
    }
  }
}

The result's own Markdown is not sent as a file. multipart_file sends each file as one form part named file_field; raw_file sends the file bytes as the whole body.

Captions

The agent that works the card writes captions in a fenced block tagged bakedbrie-captions in its result:

```bakedbrie-captions
{"caption": "Short caption for most places", "hashtags": ["#one", "#two"], "platform_captions": {"X": "Caption for X"}}
```

{{result.captions.caption}} is that caption. Without a block, it is the result's Markdown with fenced blocks removed. {{result.captions.hashtags}} is the list, {{result.captions.hashtags | join:space}} the same list as text, and {{result.captions.platform_captions.X}} one platform's own caption. When the board has a check rule and the result has no block, the check's block is used. A block that is not valid JSON stops the delivery before anything is sent, and preview with that card shows the problem.

{
  "base_url": "https://api.example.com/v1",
  "auth": {"header": "Authorization", "prefix": "Bearer "},
  "actions": {
    "deliver": {
      "steps": [
        {
          "name": "post",
          "path": "/posts",
          "body": {
            "text": "{{result.captions.caption}}\n\n{{result.captions.hashtags | join:space}}",
            "short_text": "{{result.captions.platform_captions.X | optional}}"
          },
          "save": {"id": "$.id", "url": "$.url"},
          "require": ["id"],
          "receipt": ["id", "url"],
          "external_id": "id"
        }
      ]
    }
  }
}

Targets: one call per account

targets lists the accounts. A step with for_each: targets runs once for each, with that target's fields in {{target.<field>}}. A target's variant picks a named variant of the step (its own path or body). Each target gets its own receipt line; one that fails does not stop the others, and the delivery then says failed.

{
  "base_url": "https://api.example.com/v1",
  "auth": {"header": "Authorization", "prefix": "Bearer "},
  "targets": [
    {"key": "brand", "account": "acct_brand"},
    {"key": "founder", "account": "acct_founder", "variant": "short"}
  ],
  "actions": {
    "deliver": {
      "steps": [
        {
          "name": "post",
          "path": "/posts",
          "for_each": "targets",
          "body": {"text": "{{result.captions.caption}}", "account": "{{target.account}}"},
          "variants": {"short": {"body": {"text": "{{result.captions.caption | clip:280}}", "account": "{{target.account}}"}}},
          "save": {"id": "$.id", "url": "$.url"},
          "require": ["id"],
          "receipt": ["id", "url"],
          "external_id": "id"
        }
      ]
    }
  }
}

Posting times

slots sets the posting times. A step with kind: reserve_slot gives the card the next open time, at least 30 minutes ahead and never one another card of this destination holds. It sends nothing to the service; a retry keeps the same time. Use it as {{steps.slot.slot}} (ISO 8601, UTC).

{
  "base_url": "https://api.example.com/v1",
  "auth": {"header": "Authorization", "prefix": "Bearer "},
  "slots": {"timezone": "America/New_York", "daily_times": ["09:00", "13:00"], "include_weekends": false},
  "actions": {
    "deliver": {
      "steps": [
        {"name": "slot", "kind": "reserve_slot", "save": {"slot": "$.slot"}, "receipt": ["slot"]},
        {
          "name": "post",
          "path": "/posts",
          "body": {"text": "{{result.captions.caption}}", "publish_at": "{{steps.slot.slot}}"},
          "save": {"id": "$.id", "url": "$.url"},
          "require": ["id"],
          "receipt": ["id", "url"],
          "external_id": "id"
        }
      ]
    }
  }
}

Unclear sends

A timeout, a lost connection or a 5xx answer after a post was sent means BakedBrie cannot know whether it landed. The step is then outcome unknown, and BakedBrie never sends it again: redeliver does not repeat it either. Leave retry_safe off for any step that creates something (it is off by default for POST, PUT, PATCH and DELETE; a GET is always safe to repeat).

Add reconcile so a redeliver looks for the post instead: it lists posts (up to max_pages pages), and when exactly one matches every match rule it adopts that post's id and the delivery succeeds. No match, or more than one, stays outcome unknown for a person to check.

{
  "base_url": "https://api.example.com/v1",
  "auth": {"header": "Authorization", "prefix": "Bearer "},
  "actions": {
    "deliver": {
      "steps": [
        {
          "name": "post",
          "path": "/posts",
          "body": {"text": "{{result.captions.caption}}"},
          "save": {"id": "$.id", "url": "$.url"},
          "require": ["id"],
          "receipt": ["id", "url"],
          "external_id": "id",
          "reconcile": {
            "path": "/posts?text={{result.captions.caption}}&limit=100",
            "items": "$.posts",
            "next_cursor": "$.next_cursor",
            "cursor_param": "cursor",
            "match": [{"path": "$.text", "equals": "{{result.captions.caption}}"}],
            "save": {"id": "$.id", "url": "$.url"}
          }
        }
      ]
    }
  }
}

Error mapping

Without mapping, a 401 or 403 is DESTINATION_AUTH_FAILED, a 429 is retried at most twice after the service's Retry-After (up to 5 seconds) and then RATE_LIMITED, another 4xx is HTTP_<status> (for example HTTP_422), and a 5xx is API_UNAVAILABLE.

error names where the service puts its own error code; BakedBrie turns it into an upper-case code with your prefix (text_required becomes EXAMPLE_TEXT_REQUIRED). ok_when is for a service that answers 200 even when it refused: the step succeeds only when the value at path equals equals, and otherwise fails with the mapped code, or error.code when the answer has none (API_NOT_OK when the step has no error). maybe_sent lists the service's codes that mean it may have posted anyway; those are outcome unknown, never resent.

{
  "base_url": "https://api.example.com/v1",
  "auth": {"header": "Authorization", "prefix": "Bearer "},
  "actions": {
    "deliver": {
      "steps": [
        {
          "name": "post",
          "path": "/posts",
          "body": {"text": "{{result.captions.caption}}"},
          "ok_when": {"path": "$.ok", "equals": true},
          "error": {"path": "$.error", "prefix": "EXAMPLE_", "code": "EXAMPLE_NOT_OK", "maybe_sent": ["internal_error"]},
          "save": {"id": "$.post.id"},
          "require": ["id"],
          "receipt": ["id"],
          "external_id": "id"
        }
      ]
    }
  }
}

The size limit

A whole webhook definition is at most 7000 characters, counted as saved: BakedBrie fills in each field's default (for example "kind": "http" and "body_format": "json"), which adds about 70 characters per step. Other limits: 1 to 12 actions, 1 to 20 steps per action, 20 targets, and 500 characters per path. A definition over the limit is refused with INVALID_INPUT, and preview says so before you create anything. To make one smaller, use short step names and leave out fields that repeat a default.

What to expect

  • manage_destination action get shows the definition, has_credential: true, and the board it is the default for. The key is never shown.
  • Each finished card makes one delivery. list_results shows its receipt: each step with its state, HTTP status, error code and the receipt fields you chose.
  • A failed delivery can be retried with redeliver. Steps that already succeeded are not sent again, and an outcome-unknown step is looked for (reconcile), never resent.
  • Changing the definition later: manage_destination action update with the whole webhook and the expected_revision from get. Changing base_url or auth needs the key again (credential), so a saved key never follows the definition to a new place.
  • Other actions in the same definition can run on board events: see manage_board_hooks.
  • Every code: Refusals.

View as Markdown