---
name: oetzi
description: Work an Oetzi workspace as an outbound agent. Pull intent-scored conversations from social listening, the people who wrote and engaged with them, share-of-model gaps, and the post queue, then act (reply, draft, publish, record outreach) through one gated API. Use when the user mentions Oetzi, a workspace API key (oetzi_...), conversations to reply to, people to reach, or posts to draft.
---

# Oetzi

Oetzi finds the conversations that matter to a product across X, Reddit,
LinkedIn, Threads, Bluesky, Hacker News, and Substack Notes, scores each for
buying intent, builds a graph of the people around them, tracks which AI
engines recommend the product, and keeps a queue of posts and replies.
You are the outbound agent: Oetzi supplies signal and people, you act.

## Connect

One workspace API key (`oetzi_...`, minted by a human under Settings) with
scopes: read, write, spend (may use credits), post (may publish).

- MCP (preferred): `claude mcp add --transport http oetzi https://www.oetzi.ai/api/mcp --header "Authorization: Bearer $OETZI_API_KEY"`
- REST: `POST https://www.oetzi.ai/api/v1/capabilities/{name}` with a JSON body, same names as the MCP tools. Spec: https://www.oetzi.ai/api/v1/openapi.json
- CLI: `npx @oetzi/cli login` then `oetzi run {name} --key=value`

## Rules

1. Start every session with `get_playbook`, then `list_signals`. Never rely on a remembered signal list.
2. Poll `get_leads status=new band=hot` at most every 15 minutes. Read `get_lead` before acting.
3. Before contacting anyone, `get_person` and check `outreach`. Never double-contact.
4. After acting, `mark_lead status=contacted` and `record_outreach` (channel, message, outcome, your name). This is mandatory.
5. Capabilities that spend credits return a dry-run estimate until called with `approved: true` and `max_credits`. Ask your operator before approving anything above 2 credits, and stop on `budget_exceeded` or `insufficient_credits`.
6. Publishing (`publish_post`, `post_reply`) needs a post-scoped key and `approved: true`. Only publish text the operator approved or a standing instruction covers.
7. Long work returns `job_id`. Call `wait_job` until status is done or error.
8. Push: `wait_events` after a cursor from `list_events` for `lead.hot`, `person.discovered`, `post.published`, `outreach.recorded`.
9. Rate matches honestly with `rate_lead`. Labels tune the judge overnight.

## The loop

```
get_playbook → list_signals → get_leads(status=new, band=hot)
  → get_lead → draft_reply → (post_reply | reply yourself) → record_outreach → mark_lead
  → pull_engagers on the strong ones → list_people(lead_id) → resolve_person for LinkedIn people worth email
```

## Errors

approval_required, budget_exceeded, insufficient_credits, scope_denied,
not_found, invalid_input, rate_limited, conflict, unsupported, internal.
scope_denied means ask the operator for a wider key, never retry.

## Capabilities (52)

## Discovery

### capabilities_search

Find capabilities by what you want to do (for example "people who liked a post" or "pause a signal"). Returns names, scopes, and whether each spends credits. Follow with capabilities_schema for exact inputs.

_scope: read_

Parameters:
  - query: string · Free text. Omit to list everything.
  - limit: integer · 1..50
Example input: `{"query":"run a sweep now"}`

### capabilities_schema

Exact input schema (JSON Schema) for one capability, plus its scope, whether it spends credits, and whether it returns a job_id.

_scope: read_

Parameters:
  - name (required): string
Example input: `{"name":"run_signal_now"}`

## Workspace

### get_workspace

This workspace's name, plan, credit balance (credits are the meter for sweeps, checks, renders, and pulls), and the scopes of the key you are using. Check the balance before spending.

_scope: read_

No parameters.

### get_playbook

The out-of-the-box playbook for this workspace: the current signal list, the recommended loop (refresh, poll, act, mark), and the spending rules. Call this first in a new session, and re-call it to refresh your view of what the workspace is listening for.

_scope: read_

No parameters.

## Brain

### list_lessons

What this workspace has taught Oetzi by reacting to its work: standing rules from rejected or edited post drafts (the drafter reads these every time), notes on rendered videos, and the 👍/👎 record on matched conversations grouped by signal and by reason. Read this before drafting anything in the workspace's voice.

_scope: read_

No parameters.
Example input: `{}`

### forget_lesson

Remove one standing post rule or video note so it stops shaping future drafts. Match ratings cannot be forgotten here, re-rate the conversation with rate_lead instead.

_scope: write_

Parameters:
  - kind (required): string · one of: post_rule | video_note
  - id (required): string · uuid · The lesson id from list_lessons
Example input: `{"kind":"post_rule","id":"<lesson id>"}`

### get_competitor_report

How the market talks about each competitor in the Brain over the last 90 days: matched conversations that name them, how many were complaints, how many were hot, which platforms, three recent examples with lead ids, and the people who were in those threads (feed them to list_people or record_outreach).

_scope: read_

Parameters:
  - competitors: array · Override the Brain's competitor list for this call
Example input: `{}`

### get_knowledge_graph

The workspace as a graph: the brand node, the segments it serves, the content angles it owns, the competitors the market names next to it, and the most recently active people, each linked to the hub they engaged around (a competitor's thread, or the brand). Nodes carry portal hrefs. Use it to pick who to reach and what to say.

_scope: read_

Parameters:
  - people: integer · 0..80 · How many people to include (default 40)
Example input: `{"people":40}`

### get_voice

The workspace's voice: the markdown voice doc (how the author writes: openings, rhythm, vocabulary, always/never rules), the samples of their own writing every draft is few-shot on, what they said about past drafts (sounds like me / not me), and which connected accounts can be pulled for more samples. Read it before writing anything on the workspace's behalf outside draft_reply and draft_posts, which already do.

_scope: read_

No parameters.
Example input: `{}`

### set_voice

Replace the voice doc with the markdown you pass (up to 8,000 chars). Every reply and post drafted after this reads it whole. Pass an empty string to clear it and fall back to the one-line tone from the website scrape. Free.

_scope: write_

Parameters:
  - doc (required): string · Markdown. See get_voice for the current one and the section shape generate_voice_doc uses.
Example input: `{"doc":"# Voice\n\n## In one line\nDry, technical, allergic to hype.\n\n## Never\n- open with a question"}`

### add_voice_samples

Add samples of the author's own writing (posts, emails, DMs they actually wrote, 20 to 2,000 chars each). Newest samples are few-shot in every draft, up to 20 are kept. Duplicates are dropped. Free.

_scope: write_

Parameters:
  - samples (required): array · Texts the author wrote themselves
Example input: `{"samples":["Spent the weekend ripping out our onboarding flow. Three screens became one. Activation went from 31% to 44%. The lesson was not clever, we just deleted things."]}`

### remove_voice_sample

Drop one voice sample by id (ids come from get_voice). Free.

_scope: write_

Parameters:
  - sample_id (required): string · uuid
Example input: `{"sample_id":"<sample id>"}`

### pull_voice_samples

Pull the author's own recent posts from a connected account as voice samples: X (the workspace's connected account, replies and reposts excluded) or Bluesky (public feed). LinkedIn does not expose a member's posts to apps, paste those with add_voice_samples. Free, no vendor cost.

_scope: write_

Parameters:
  - platform (required): string · one of: x | bluesky
  - limit: integer · 1..30 · How many recent posts to take (default 15)
Example input: `{"platform":"x"}`

### generate_voice_doc

Draft the voice doc from evidence: the voice samples, the posts approved in the queue, and the website's one-line tone. Overwrites the current doc, so read get_voice first if a person edited it by hand. Free, one model call.

_scope: write_

No parameters.
Example input: `{}`

### rate_voice

Say whether a drafted reply or post sounded like the author. not_me with a note ("too salesy", "I never say folks") becomes a standing never-rule for every later draft; me examples are shown to the drafter as register. Free.

_scope: write_

Parameters:
  - source (required): string · one of: reply | post
  - ref_id (required): string · The conversation id (optionally :variant index) or the social post id
  - verdict (required): string · one of: me | not_me
  - excerpt (required): string · The draft text being judged
  - note: string · Why, in the author's words
Example input: `{"source":"reply","ref_id":"<lead id>:0","verdict":"not_me","excerpt":"Great question! ...","note":"I never open with Great question"}`

## Signals

### list_signals

List this workspace's listening signals: the natural-language definitions of the conversations being watched, with id, status, and cadence. Use the ids to filter get_leads.

_scope: read_

No parameters.

### get_signal

One signal in full: definition, status, cadence, the platform queries it compiles to, and its last runs.

_scope: read_

Parameters:
  - signal_id (required): string · uuid · Signal id from list_signals

### set_signal_status

Pause or resume a listening signal. Paused signals stop producing matches until reactivated. Draft signals cannot be activated here, they need compilation in the app first.

_scope: write_

Parameters:
  - signal_id (required): string · uuid · Signal id from list_signals
  - status (required): string · one of: active | paused

### run_signal_now

Sweep one signal now across every platform it watches, instead of waiting for its cadence. Costs 2 credits, refunded automatically when nothing qualifies. Rate limited to one manual sweep per signal every 15 minutes.

_scope: spend · spends credits (sweep) · async, returns job_id_

Parameters:
  - signal_id (required): string · uuid · Signal id from list_signals
  - dry_run: boolean · true = estimate only, nothing runs and nothing is charged
  - approved: boolean · Required to run a capability that spends credits. Omit it to get a dry-run estimate first.
  - max_credits: number · Ceiling for this call. The call fails with budget_exceeded when the estimate is higher.
Example input: `{"signal_id":"<signal id>","approved":true,"max_credits":2}`

## Conversations

### get_leads

Fetch qualified conversations from this workspace: public posts scored for buying intent, each with the post content (truncated at 1,200 chars, use get_lead for full detail), author, platform URL, intent score (0-100), band (hot/warm/review), a why-it-matched rationale, and a ready-to-send reply draft when available. Filter by signal, status, band, audience (who is talking: in-market buyer, current customer, feature request, competitor user), or time, and sort newest (default), oldest, or by score. Conversations default to status 'new', poll with status=new to drive your loop, then mark_lead what you action.

_scope: read_

Parameters:
  - signal_id: string · uuid · Limit to one signal (see list_signals)
  - status: string · one of: new | saved | contacted | dismissed · Workflow status, 'new' = not yet actioned
  - band: string · one of: hot | warm | review · hot = strongest intent
  - audience: string · one of: in_market_buyer | current_customer | feature_request | competitor_user | other · WHO is talking: current_customer = churn save / product feedback, feature_request = roadmap input, competitor_user = switch target, in_market_buyer = market to them
  - since: string · date-time · ISO 8601 timestamp, only conversations delivered after this
  - sort: string · one of: newest | oldest | score · newest (default) | oldest | score (intent, descending)
  - limit: integer · 1..50 · Max results, 1-50 (default 20)
Example input: `{"status":"new","band":"hot","limit":10}`

### get_lead

Full detail for one conversation: untruncated post content, thread context, author profile fields, reply draft variants, enriched contact when present, and current status/feedback. Call before acting on a conversation you found via get_leads.

_scope: read_

Parameters:
  - lead_id (required): string · uuid · Conversation id from get_leads

### mark_lead

Update a conversation's workflow status after acting on it: 'contacted' when outreach was sent, 'saved' to shortlist, 'dismissed' to skip. Keeps the human inbox in sync with what the agent has done.

_scope: write_

Parameters:
  - lead_id (required): string · uuid · Conversation id from get_leads
  - status (required): string · one of: saved | contacted | dismissed

### rate_lead

Quality feedback on a conversation, the same 👍/👎 as the human inbox, feeding the signal-tuning loop. feedback='good' confirms the match, feedback='not_relevant' marks a miss and dismisses it. Labels tune the signal's judge overnight, so rate accurately and pass a reason when one fits.

_scope: write_

Parameters:
  - lead_id (required): string · uuid · Conversation id from get_leads
  - feedback (required): string · one of: good | not_relevant
  - reason: string · one of: wrong_persona | wrong_topic | too_old | competitor_employee · Why it was not relevant (only with feedback=not_relevant)

### draft_reply

Generate two voice-tuned reply variants for a conversation, each with a why-this-angle note, written for its platform norms. Stored on the conversation, so the human inbox sees the same drafts. 1 credit for the first draft on a conversation, every redraft after that is free. A failed generation refunds itself.

_scope: spend · spends credits (draft)_

Parameters:
  - lead_id (required): string · uuid · Conversation id from get_leads
  - dry_run: boolean · true = estimate only, nothing runs and nothing is charged
  - approved: boolean · Required to run a capability that spends credits. Omit it to get a dry-run estimate first.
  - max_credits: number · Ceiling for this call. The call fails with budget_exceeded when the estimate is higher.

### translate_post

Render a non-English conversation in English: the post body and its thread context, tone and handles preserved. Free. Made once per post and cached, so repeat calls return instantly. get_lead reports lang, call this when it is not 'en'.

_scope: read_

Parameters:
  - lead_id (required): string · uuid · Conversation id from get_leads
Example input: `{"lead_id":"<lead id>"}`

## People

### list_people

People who interacted with posts this workspace matched: the authors of conversations and, once engager pulls run, the people who liked, reposted, replied to, or quoted them. Filter by a conversation (lead_id), interaction kind, platform, follower floor, whether a verified contact exists, or a tag. Sorted by most recent interaction.

_scope: read_

Parameters:
  - lead_id: string · uuid · Only people who touched the post behind this conversation
  - post_id: string · uuid · Only people who touched this post (posts.id)
  - kind: string · one of: authored | liked | reposted | replied | commented | quoted
  - platform: string · x | reddit | linkedin | threads | bluesky | hn | substack
  - min_followers: integer · 0..9007199254740991
  - has_contact: boolean · true = has a verified email or phone in this workspace
  - tag: string
  - since: string · date-time · Interactions recorded after this ISO timestamp
  - limit: integer · 1..100
Example input: `{"kind":"authored","min_followers":500,"limit":20}`

### get_person

One person in full: every platform identity, this workspace's interactions with them (each with the post and the conversation it became), verified contacts, tags, and every outreach recorded against them. Check outreach before contacting anyone.

_scope: read_

Parameters:
  - person_id (required): string · uuid · Person id from list_people or a conversation's person_id

### tag_person

Add or remove free-text tags on a person for this workspace (for example "champion", "do-not-contact", "q4-list"). Tags filter list_people.

_scope: write_

Parameters:
  - person_id (required): string · uuid · Person id from list_people or a conversation's person_id
  - add: array
  - remove: array
Example input: `{"person_id":"<person id>","add":["champion"]}`

### record_outreach

Report that you contacted a person: the channel, what you sent, the outcome, and who you are. This is the handoff that keeps the graph honest, so nobody in the workspace double-contacts. Marks the linked conversation contacted when lead_id is given, and emits outreach.recorded.

_scope: write_

Parameters:
  - person_id (required): string · uuid · Person id from list_people or a conversation's person_id
  - channel (required): string · x_reply | x_dm | linkedin_dm | linkedin_comment | email | imessage | call | other
  - message: string · What was sent, verbatim or summarized
  - outcome: string · sent | replied | booked | declined | bounced | no_answer
  - agent: string · Who did it: your agent name, or a person
  - external_ref: string · Vendor message id or permalink
  - lead_id: string · uuid · The conversation this outreach answers, if any
  - metadata: object
Example input: `{"person_id":"<person id>","channel":"x_reply","outcome":"sent","agent":"claude-code","lead_id":"<lead id>"}`

### pull_engagers

Pull the people who engaged with a post (liked, reposted, quoted, replied, commented) into the people graph, then read them with list_people. Address the post by conversation (lead_id), post id, or its exact URL. Works on X, Bluesky, LinkedIn, Reddit, and Hacker News. 1 credit per post, refunded when fewer than 3 people come back, free when already pulled.

_scope: spend · spends credits (engagers) · async, returns job_id_

Parameters:
  - lead_id: string · uuid
  - post_id: string · uuid
  - url: string · uri · Exact URL of a post Oetzi has already seen
  - dry_run: boolean · true = estimate only, nothing runs and nothing is charged
  - approved: boolean · Required to run a capability that spends credits. Omit it to get a dry-run estimate first.
  - max_credits: number · Ceiling for this call. The call fails with budget_exceeded when the estimate is higher.
Example input: `{"lead_id":"<lead id>","approved":true,"max_credits":1}`

## Share of model

### list_geo_questions

The buying questions this workspace tracks across AI engines (ChatGPT, Gemini, Perplexity, Grok, Google AI Mode), as a user would type them.

_scope: read_

No parameters.

### get_share_of_model

The latest share-of-model board: for every tracked question, which engines mentioned this workspace, at what rank, which competitors they named instead, and the sources they cited. Includes the current content plan (what to publish to close the gaps) when one has been drafted.

_scope: read_

No parameters.

### run_geo_check

Ask every configured AI engine each tracked question now and record who they recommend. Costs 2 credits per active question. Rate limited to one manual board per hour.

_scope: spend · spends credits (geo) · async, returns job_id_

Parameters:
  - dry_run: boolean · true = estimate only, nothing runs and nothing is charged
  - approved: boolean · Required to run a capability that spends credits. Omit it to get a dry-run estimate first.
  - max_credits: number · Ceiling for this call. The call fails with budget_exceeded when the estimate is higher.
Example input: `{"approved":true,"max_credits":10}`

## Content

### list_content

Short-form videos and carousels this workspace has generated, with status (rendered = awaiting review, accepted = approved, posted) and download URLs for rendered ones.

_scope: read_

Parameters:
  - status: string · one of: draft | rendered | accepted | rejected | superseded | archived | posted
  - limit: integer · 1..50

### generate_content

Plan and render new short-form videos or carousels from the workspace brain and asset library. Costs 1 credit per rendered item, failed renders are free. Optional brief steers the whole batch.

_scope: spend · spends credits (video) · async, returns job_id_

Parameters:
  - count: integer · 1..5 · How many to plan, 1-5 (default 1)
  - brief: string · A topic or request the whole batch must be about
  - dry_run: boolean · true = estimate only, nothing runs and nothing is charged
  - approved: boolean · Required to run a capability that spends credits. Omit it to get a dry-run estimate first.
  - max_credits: number · Ceiling for this call. The call fails with budget_exceeded when the estimate is higher.
Example input: `{"count":2,"brief":"our pricing page launch","approved":true,"max_credits":2}`

## Publishing

### list_social_accounts

Accounts this workspace can publish through: natively connected X, LinkedIn, Bluesky, and a Postiz account with its channels (TikTok, Instagram, YouTube, Threads, and more). Replies need a native account; Postiz publishes originals and video only.

_scope: read_

No parameters.

### list_posts

The post queue: drafts waiting for review, approved and scheduled posts, what was published (with URLs), failures with their error. Filter by status, platform, kind (original | reply), or a GEO plan item key.

_scope: read_

Parameters:
  - status: string · one of: draft | approved | scheduled | posted | failed | rejected
  - platform: string
  - kind: string · one of: original | reply
  - plan_item_key: string
  - limit: integer · 1..200
Example input: `{"status":"draft","limit":20}`

### get_post

One queued or published post in full, with its topic, rationale, reply target, media, and publish result.

_scope: read_

Parameters:
  - post_id (required): string · uuid · Post id from list_posts

### create_post

Put a post you wrote into the queue as a draft (or already approved) for a platform: x, linkedin, bluesky, threads, or any Postiz channel provider (tiktok, instagram, youtube). Publish it with publish_post. Character limits are enforced per platform.

_scope: write_

Parameters:
  - platform (required): string
  - body (required): string
  - approve: boolean · true = lands approved instead of draft (still needs publish_post to go out)
  - media_from_content_id: string · uuid · Attach a rendered video or carousel from list_content
  - rationale: string
Example input: `{"platform":"linkedin","body":"Three things we learned...","approve":false}`

### draft_posts

Draft original posts in the workspace's voice for one platform, from a topic: a share-of-model plan item (plan_item_key from get_share_of_model), a brief, verbatim hot-match phrasings (lead_ids), or a seed post to riff on. Drafts land in the queue for review. 1 credit per run (one platform, up to 3 variants). Running the same topic on the same platform again is free, and seed_post_id (more like this) is always free. A run that produces nothing refunds itself. Returns a job_id.

_scope: spend · spends credits (draft) · async, returns job_id_

Parameters:
  - platform (required): string
  - count: integer · 1..3
  - brief: string
  - plan_item_key: string
  - lead_ids: array
  - seed_post_id: string · uuid
  - dry_run: boolean · true = estimate only, nothing runs and nothing is charged
  - approved: boolean · Required to run a capability that spends credits. Omit it to get a dry-run estimate first.
  - max_credits: number · Ceiling for this call. The call fails with budget_exceeded when the estimate is higher.
Example input: `{"platform":"linkedin","plan_item_key":"post:...","count":2}`

### review_post

Approve, reject, or edit a queued post. Rejections and edits can carry feedback, which becomes a standing rule for future drafts. Approving does not publish, call publish_post for that.

_scope: write_

Parameters:
  - post_id (required): string · uuid · Post id from list_posts
  - action (required): string · one of: approve | reject | edit
  - body: string · New text, for action=edit
  - feedback: string · Why, kept as a standing rule for the drafter
Example input: `{"post_id":"<post id>","action":"reject","feedback":"Too salesy, never open with the product name"}`

### publish_post

Publish a queued post under the workspace's name through the connected account for its platform (Postiz for its channels). Marks it approved by you. Optional schedule_for (ISO) queues it for later. Free, but requires approved: true and a post-scoped key. Returns a job_id unless scheduled.

_scope: post · async, returns job_id_

Parameters:
  - post_id (required): string · uuid · Post id from list_posts
  - schedule_for: string · date-time
  - dry_run: boolean · true = estimate only, nothing runs and nothing is charged
  - approved: boolean · Required to run a capability that spends credits. Omit it to get a dry-run estimate first.
  - max_credits: number · Ceiling for this call. The call fails with budget_exceeded when the estimate is higher.
Example input: `{"post_id":"<post id>","approved":true,"max_credits":0}`

### post_reply

Reply to a matched conversation on its platform, under the workspace's connected account (X, LinkedIn comment, Bluesky). Pass the text, or omit it to send the first draft_reply variant. Marks the conversation contacted and records the outreach. Free, requires approved: true and a post-scoped key.

_scope: post · async, returns job_id_

Parameters:
  - lead_id (required): string · uuid
  - text: string
  - dry_run: boolean · true = estimate only, nothing runs and nothing is charged
  - approved: boolean · Required to run a capability that spends credits. Omit it to get a dry-run estimate first.
  - max_credits: number · Ceiling for this call. The call fails with budget_exceeded when the estimate is higher.
Example input: `{"lead_id":"<lead id>","text":"Same here, we moved off X last year because...","approved":true,"max_credits":0}`

### get_posting_plan

The drafting cadence per platform: how often Oetzi drafts original posts (off | daily | every2d | weekly), how many per run, whether autopilot publishes them without a review, and when it last ran.

_scope: read_

No parameters.

### set_posting_plan

Set the drafting cadence for one platform. Each run is 1 credit (1 to 3 posts) and drafts land in the queue for review. autopilot: true publishes them without a review, spread across the day, only when an account can publish there. Off by default.

_scope: write_

Parameters:
  - platform (required): string
  - cadence (required): string · one of: off | daily | every2d | weekly
  - per_run: integer · 1..3
  - autopilot: boolean
Example input: `{"platform":"linkedin","cadence":"every2d","per_run":1,"autopilot":false}`

## Jobs

### get_job

State of a background job: status (running | done | error), phase (queued | running | done | error), progress when the worker reports it, the result once done, and the error otherwise.

_scope: read_

Parameters:
  - job_id (required): string · uuid · job_id returned by an async capability

### wait_job

Block until a job finishes, up to 25 seconds per call, then return its state. Call it again while status is still running. Cheaper than polling get_job in a loop.

_scope: read_

Parameters:
  - job_id (required): string · uuid · job_id returned by an async capability
  - timeout_ms: integer · 1000..25000 · Default and maximum 25000

## Events

### list_events

Events in this workspace after a cursor: conversations delivered (lead.delivered, lead.hot), people discovered, posts published, share-of-model checks, jobs completed, outreach recorded. Returns up to 200 in id order. Start with since=0, then pass the last id you saw. Call with no since to get the current cursor.

_scope: read_

Parameters:
  - since: integer · 0..9007199254740991 · Exclusive cursor (event id). Omit to learn the latest id.
  - types: array · Any of: lead.delivered, lead.hot, person.discovered, post.published, geo.check.completed, job.completed, outreach.recorded
  - limit: integer · 1..200
Example input: `{"since":0,"types":["lead.hot"],"limit":50}`

### wait_events

Block until at least one event after the cursor lands, up to 25 seconds per call, then return the batch. Loop on it for a push-style agent without running a webhook endpoint.

_scope: read_

Parameters:
  - since (required): integer · 0..9007199254740991 · Exclusive cursor (event id)
  - types: array · Any of: lead.delivered, lead.hot, person.discovered, post.published, geo.check.completed, job.completed, outreach.recorded
  - limit: integer · 1..200
  - timeout_ms: integer · 1000..25000
Example input: `{"since":120,"types":["lead.hot","person.discovered"]}`

### list_webhooks

Webhook subscriptions in this workspace: URL, event types, active flag, last status, failure count. Secrets are never returned after creation.

_scope: read_

No parameters.

### subscribe_webhook

Register an https URL to receive events as signed JSON POSTs (header X-Oetzi-Signature = sha256 HMAC of the body with the returned secret). Pass event_types or ["*"] for everything. A subscription is disabled after 20 consecutive failures. The secret is returned once.

_scope: write_

Parameters:
  - url (required): string · uri
  - event_types (required): array
  - label: string
Example input: `{"url":"https://example.com/oetzi","event_types":["lead.hot","person.discovered"]}`

### unsubscribe_webhook

Remove a webhook subscription by id.

_scope: write_

Parameters:
  - id (required): string · uuid
