# Synorb agent guide: worked recipes

This page is part of the Synorb agent guide at https://synorb.com/agent-guide.md.
Five copy-paste recipes: a morning update, a Beacon watch with incremental polling, deep research, due diligence, and meeting prep.

## Worked recipes

The MCP equivalent uses the same sequence: catalog/details/tag resolution, then
`synorb-manifests`. The REST pairs below are deliberately copy-pasteable.

### 1. Morning digest across surfaces

Goal: give a compact source-cited morning update from selected Surfaces.

1. `GET /ontology/tags?search=Payment%20Systems`
2. `POST /manifests/plan` with resolved `stream_ids`, Tag ID, and `days=1`
3. `POST /manifests/exec` with the exact planned request plus `plan_token`

```json
// plan request
{"stream_ids":[123,456],"tag_ids":["tag_123"],"days":1,"mode":"default","target_count":8}
// execute response (abridged)
{"data":{"manifests":[{"manifest_id":"m_1","brief":{"headline":"..."},"citations":[{"url":"https://source.example/evidence"}]}],"presentation_items":[{"manifest_id":"m_1","title":"Source title","source_url":"https://source.example/item","link_type":"page","action":{"label":"Read source","kind":"read","url":"https://source.example/item"}}]},"quota":{"manifests_billed_this_call":1}}
```

Cost: Tag lookup and plan are zero; the execute bills only returned on-topic
Manifests. Failure modes: an unknown Tag gives an empty result. An absent scope
can broaden to global. A changed execute payload gives `invalid_plan_token`,
and you must re-plan it.

### 2. Watch this for me with a Beacon

Goal: save a reusable watch, then pull it with an explicit time window.

Start with the Beacons that already exist. Every account has pre-saved starter
Beacons. List them with `GET https://api.synorb.com/v3/beacons?tag=starter`
(the path is `/v3/beacons`, not `/beacons`). Pull one with its `beacon_id` and an
explicit lookback before you save a new Beacon.

Resolve the canonical topic first. `natural_language_intent` is descriptive
metadata only; it is not executable. Translate it into `stream_ids` and
`topic_filters` before you create the Beacon.

```bash
curl -sS -X POST https://api.synorb.com/v3/beacons \
  -H "Authorization: Bearer $SYNORB_KEY" -H 'content-type: application/json' \
  -d '{
    "name":"Payment systems watch",
    "natural_language_intent":"Watch payment infrastructure changes",
    "stream_ids":[123,456],
    "topic_filters":[{"tag_id":"tag_123","tag_name":"Payment Systems","tag_type":"topic"}],
    "default_date_window":{"lookback_hours":24}
  }'
```

```json
{"beacon_id":"synorb_bcn_...","name":"Payment systems watch","status":"draft","topic_filters":[{"tag_id":"tag_123","tag_name":"Payment Systems","tag_type":"topic"}]}
```

A new Beacon defaults to `"draft"`, but you can already poll it. `"draft"` and
`"active"` behave identically; only `"archived"` stops execution. You do not
need a separate activation call to make a Beacon live. If you want the status
of the row to say `"active"`, pass `status:"active"` on create. You can also
send `PATCH /v3/beacons/{id}` with `{"status":"active"}` afterward.

One catch: you must create an explicit `status:"active"` Beacon with real scope
(`stream_ids`, `source_channel_ids`, `topic_filters`, or `entity_filters`).
`"draft"` is the only status exempt from that check. That is why the example
above omits `status` and gets `"draft"` by default.

`topic_filters` is an array. Discover Tags with `GET /ontology/tags?search=...`.
From `data.tags[]`, copy `tag_id` and `tag_type`. Map `canonical_name` to the
`tag_name` field of the Beacon. Do not guess a Tag name or ID.

For a preference change, PATCH the existing Beacon in place. Do not archive and
recreate it:

```bash
curl -sS -X PATCH https://api.synorb.com/v3/beacons/$BEACON_ID \
  -H "Authorization: Bearer $SYNORB_KEY" -H 'content-type: application/json' \
  -d '{"stream_ids":["<stream-id-from-discovery>"],"topic_filters":[{"tag_id":"<tag-id-from-discovery>","tag_name":"<tag-name-from-discovery>","tag_type":"topic"}],"default_date_window":{"lookback_hours":48}}'
```

PATCH replaces each supplied field; omitted fields stay unchanged. Read the
Beacon first, and keep the entries you want when you replace `topic_filters` or
`manifest_config`. Replace placeholders with discovered values. Then call
`GET /v3/beacons/{id}` and confirm the saved scope and window.

Over MCP, use `synorb-update-beacon`. First read the Beacon with `synorb-beacons`
and `beacon_id`. Pass its `revision` as `expected_revision`. The default `dry_run`
returns a before and after diff and writes nothing. Ask your user. Then send the call
again with `dry_run` set to false.

A change that adds scope needs `allow_widening` set to true. `manifest_config_patch`
merges by key. A starter Beacon keeps its `surface_ids`.

REST `PATCH` also takes `expected_revision`. A stale revision returns HTTP 409.

For a Manifest query, "the past couple of days" maps to `days=2`, and "last
week" maps to `days=7`. Both anchor to `available_through` by default. Show the
effective inclusive dates. For a Beacon GET, pass `lookback_hours=48`. This
anchors to the current UTC date and remains limited by plan availability.

The saved default window is guidance; each pull must still send its window
explicitly. After restore, the Beacon returns as `draft`. Draft Beacons are
pollable. If the user wants an active status, PATCH `{"status":"active"}` and
GET the Beacon again to verify.

Poll using the path that executes Beacons:

```bash
curl -sS -H "Authorization: Bearer $SYNORB_KEY" \
  'https://api.synorb.com/streams/123/manifests?beacon_id=synorb_bcn_...&lookback_hours=24'
```

`POST /manifests/query` also accepts `beacon_id` directly. It uses the same
saved-scope merge, `broaden_sources`, and `executed_via_beacon` reporting that
its `ManifestQueryRequest.beacon_id` field docs describe. Use it if you prefer
one call shape for both ad hoc queries and Beacon runs. Either path resolves the
same saved scope, bills the same way, and requires the same explicit date
window. Pick the path that fits the rest of your integration.

For default pulls across several explicit Streams, with no search text or Tag
filters, Synorb can rotate among source channels before it splits pages. Each
source keeps its original order. Every item stays within the requested date
window; this can include older items from other sources. The `source_balance`
receipt reports whether Synorb applied mixing or kept the usual order.

Starter Beacons keep their own source caps. Continue a saved pagination session
to keep its original order, including sessions created before this policy.

When you deliver poll results to a human who reads a screen, present them as
a briefing, not as a long block of prose. Use one lead line, then one story
per item that matters, written as a take with its evidence labelled. Each story
has a why-it-matters line for this user and the source link inline. End with an
index of every source. The full contract is in
https://synorb.com/agent-resources/output-contract.md

Every Manifest delivery carries `presentation_items`. There is one entry per
Manifest, keyed by `manifest_id`. Each entry has `title`, `why_it_matters`,
`source_url`, `date`, `category`, plus `headline` / `one_liner` / `full`
granularities, and `source_label`. Manifest and Beacon reads also carry a
`render` hint (`suggested_format`: `briefing`, `single_highlight` or `digest`,
and `list` for a Beacon inventory; `max_items`; `headline`; `note`, which holds
the contract; and sometimes `also_suitable`). The layout fields are hints;
adapt them to your interface.

In chat, rank by `presentation_items[].significance` and lead with `high`.
Group first-party company news separately from research. Demote
low-significance items to "also seen", or omit them. Match each item to its
Manifest by `manifest_id`. `presentation_items[].source_url` is the canonical
link.

Follow the complete synthetic response and rendered example in
https://synorb.com/agents/personal-agent-onboarding.txt § "How to deliver to
your user". After delivery, ask whether the mix was useful. Offer to PATCH the
existing Beacon for more company news, fewer papers, or named interests.

`full` and `source_label` live only inside each `presentation_items` entry. A
Manifest object itself never carries top-level `full` or `source_label` keys,
in the `full` or the `slim` response profile. For a single-Manifest read
(`/manifests/by-id/{id}`), `presentation_items` sits one level down, inside the
object of that Manifest, not at the response root. If you check for these
fields, look there, not at the top-level keys of the Manifest.

Group same-story Manifests into one item. Never paste raw Manifest/Signal JSON
or a bare headline dump.

The full contract is in https://synorb.com/agents/personal-agent-onboarding.txt
§ "How to deliver to your user". It says when to render a chart/stat card. It
also says what to do when Manifests have `source.content_quality: "thin"`. That
field sits under `source`; it is not a top-level Manifest field. Reference
https://synorb.com/synorb-instructions.md for a ready-to-paste form of the same
rules. Use it if your setup supports keeping notes like that across sessions.

`presentation_items[].source_url` is the canonical item link for that delivery.
It can identify an article page or a raw media file. Match each presentation
entry to its Manifest by `manifest_id`, and use `source_url` as its
destination. When `action` is an object, use `action.label` to style the link.
When `action` is null, present the item without a link.

Citation entries, when present, are separate evidence metadata. Synorb returns
only valid absolute HTTP(S) item URLs there. If the item URL is missing,
invalid, or null, leave the item without a link. Do not invent a link or
substitute a source channel homepage. A zero citation count does not mean that
the item URL is unavailable.

`presentation_items[].link_type` describes the link, not the media of the
content. An audio episode can have a `page` link to its landing page. Read
`media_format` to tell audio from text.

Sometimes a returned text page on a podcast path has the same original title
and publication date as a returned audio item. Then its presentation carries
`content_label: "Podcast page"` and `related_manifest_ids` for that audio. Both
Records and citations remain. This hint does not prove a canonical episode
identity or collapse billing.

By-ID reads return Manifests that are still eligible for publication
(`complete` or `routed`) and have their source Record available. An ID is not a
promise of permanent publication. A Record that Synorb holds after a correction
can return 404 even when the internal row still exists. A 404 alone does not
establish that its history was purged. Discover the currently published
replacement through its Stream. Do not assume that the old ID now refers to the
replacement.

Lifecycle (all zero-usage):

```bash
curl -X POST -H "Authorization: Bearer $SYNORB_KEY" https://api.synorb.com/v3/beacons/synorb_bcn_.../archive
curl -X POST -H "Authorization: Bearer $SYNORB_KEY" https://api.synorb.com/v3/beacons/synorb_bcn_.../restore
curl -X DELETE -H "Authorization: Bearer $SYNORB_KEY" https://api.synorb.com/v3/beacons/synorb_bcn_...
```

Cost: CRUD is zero; a poll bills only returned on-topic Manifests.

Failures: an archived Beacon returns `beacon_archived`. A request with no
explicit lookback or date returns `date_window_required`. Synorb rejects a
Stream outside the saved scope of the Beacon, unless you supply an explicit
override. For that dimension, use `override_streams`,
`override_source_channels`, or `override_topics`. Or use
`broaden_sources=true` to replace the saved scope deliberately.

Do not use an override to compensate for an unresolved Tag or an empty scope.
Do not send a Beacon to `POST /manifests/query`. Poll it with
`GET /streams/{stream_id}/manifests?beacon_id=BEACON_ID&lookback_hours=24`.

Use a Beacon to watch when the user needs repeated updates. Pull directly for a
one-off question.

#### Incremental polling: overlap window + dedupe, not "since last success"

Synorb does not run delivery on a clock and keeps no "since last run" cursor.
Your agent owns the polling cadence (see https://synorb.com/connect-guide). So
the `lookback_hours` of each poll is a window you choose, not a gap since your
last successful call.

A request for exactly the gap since the last success is fragile. A missed run,
a retried call, or ordinary clock skew between your host and Synorb can lose
whatever fell in the uncovered gap. No error tells you that it happened.

The fix is three independent habits, not a state machine to build:

1. **Overlap every poll with the previous one.** Re-request a fixed window that
   is wider than the interval on every run. Use `days: 2` for a daily job and
   `lookback_hours: 48` for a six-hour job. A 24-hour lookback does not overlap.
   The window rounds up to whole publication days, so on a Daily Batch plan it
   covers exactly one day.
2. **Dedupe on `manifest_id`**, because the overlap means the same Manifest can
   legitimately arrive more than once. Treat the id as an opaque **string**. The
   ids are about 1.79e18, which is larger than the safe integer range of
   JavaScript. Never parse one as a number.
3. **Seed the dedupe store** with the ids of the update you already showed the
   user. Do this before the first scheduled run.

Poll the Beacon, not one Stream. A Beacon can span many Streams, and
`beacon_id` on `POST /manifests/query` covers all of them.

```python
# Run on a fixed interval (for example daily). The window is intentionally
# wider than the interval, so a missed or delayed run is still covered.
seen = load_seen_ids()  # your own durable store of STRING ids, not a Synorb cursor

resp = requests.post(
    "https://api.synorb.com/manifests/query",
    json={"beacon_id": beacon_id, "days": 2, "page_size": 50},
    headers={"Authorization": f"Bearer {SYNORB_KEY}"},
    timeout=30,
)
resp.raise_for_status()
data = resp.json()["data"]  # an object; the list is data["manifests"]
manifests = data.get("manifests") or []
# Follow data["pagination"]["next"] verbatim until it is null.

new_manifests = [m for m in manifests if str(m["manifest_id"]) not in seen]

deliver(new_manifests)  # narrate only what is actually new to this user

# Persist ids only after delivery succeeds, so a crash re-shows items
# instead of losing them. Trim stored ids older than your window plus a margin.
seen.update(str(m["manifest_id"]) for m in new_manifests)
save_seen_ids(seen)
```

Sometimes your host misses a run entirely (the host was down, or the process
crashed). The next run still recovers everything inside the window. You lose
nothing, because you never trimmed the window to the gap alone. Widen the window
if your job can be down longer than the margin.

A `days: 2` window cannot recover an item that becomes visible three or more
days after publication. Use `days: 7` (a re-read in the same billing period
costs nothing). Or run a zero-cost `mode=count` over the full span on a regular
basis. The complete, tested script is in
[Schedule six-hour and daily pulls](https://synorb.com/agent-resources/schedule-six-hour-and-daily-pulls.md).

Daily delivery is a separate milestone. Connecting, saving a Beacon, or
delivering one update does not establish it. Synorb never runs the schedule
and never pushes results. Claim a daily watch only when you have all of these:

- a valid retained key (a demo key expires 72 hours after it is minted; to
  continue, the user must authorize an email binding)
- a persisted host job identifier
- a saved Beacon
- the effective window and the last successful run
- dedupe state keyed by `manifest_id` as a string
- retries and a failure alert

A host that cannot persist a job must say that no daily schedule is active.

### 3. Deep research across surfaces

Goal: build an evidence set across several chosen Surfaces without accidental
global pulls.

1. `synorb-catalog(query="...")` or the REST Stream catalog; select candidates.
2. `synorb-details(stream_ids=[...])`; retain the exact IDs.
3. Resolve each Tag through `/ontology/tags`.
4. Plan and execute with the chosen `stream_ids`, Tag IDs, date range, and
   `target_count`.

```json
{"stream_ids":[123,456,789],"tag_ids":["tag_123","tag_456"],"tag_logic":"and","published_date_from":"2026-09-01","published_date_to":"2026-09-06","mode":"default","target_count":20}
```

Expected execute response: `data.manifests[]`, `data.pagination`, source
citations, and a usage receipt. Cost: catalog/details/tag lookups and plan are
zero. Returned matches bill once after dedup. Failure modes: an `and` query can
legitimately be empty. Split it into separate scoped research lanes; do not
drop a filter without notice.

### 4. Due diligence on one organization cross-surface

Goal: compare the organization's owned and external coverage.

```json
{"stream_ids":[123,456],"tag_ids":["org_tag_123"],"published_date_from":"2026-08-07","published_date_to":"2026-09-06","mode":"default","target_count":15}
```

Expected response: only Manifests that match the resolved organization Tag from
the stated Streams. Each has its source provenance, and the cost is
matched-only. Failure modes: never substitute a similarly named organization.
Resolve a returned ambiguity first. If no selected Stream contains the Tag,
report an empty scoped result. Do not remove scope or change to global search.

### 5. Meeting prep on one company

Goal: get the few most useful, recent, citable updates.

```json
{"stream_ids":[123],"tag_ids":["org_tag_123"],"days":7,"significance":"high","mode":"default","target_count":5}
```

Expected response: up to five on-topic Manifests with sources for a meeting
update. Cost: matched-only, deduplicated. Failure modes: `orient` is only a
sample and cannot support a complete update. Use default mode for the final
five.
