For LLM agents

Agent prompt

If an LLM is writing your integration, give it this. Fifteen rules, and the four mistakes that pass code review and then lose data in production.

Point your agent at the raw copy

The prompt below is served as plain text so an agent can fetch it rather than having it pasted in by hand — which also means it gets whatever shipped rather than whatever someone copied last quarter.

Fetch
curl -s https://tahoe.workonward.com/llms-api.txt

In a coding agent, the shortest useful instruction is:

Instruction
Read https://tahoe.workonward.com/llms-api.txt and follow it exactly when
writing anything that calls the Tahoe Partner API. If a rule there conflicts
with what you would otherwise do, the rule wins.

Why a prompt and not just the reference

Most of this API behaves the way an experienced developer would guess. Four things do not, and each of them produces code that looks right, passes review, and then quietly loses data:

  1. A short page does not mean the end of a list. Filtering runs after the query, so the obvious loop condition truncates every sync.
  2. Event sequence numbers have gaps. They are other credentials’ events. Code that treats a gap as data loss re-reads for ever.
  3. Not every 429 is a rate limit. Half of them mean a dependency blinked, and backing off the whole integration is the wrong reaction.
  4. A withheld field is not an empty field. Caching it as null records “this person has no phone number” permanently.

An agent given only the endpoint list will get all four wrong, because in every case the wrong answer is the idiomatic one. Stating them as rules is what stops that.

The prompt

llms-api.txt
# Tahoe Partner API — agent instructions

You are integrating with the Tahoe Partner API. Follow these rules exactly.
Where a rule says NEVER or ALWAYS, it encodes a failure that has already
happened to someone; treat it as a constraint, not a preference.

## Base facts

- Base URL: https://tahoe.workonward.com/api/partner/v1
- Auth: `Authorization: Bearer <key>`, where the key looks like
  `thk_live_...` or `thk_test_...`. No query parameter, no cookie, no basic auth.
- API version: sent back in `Tahoe-Partner-Api-Version`. It is a date and only
  changes for a breaking change. New fields, endpoints and event types appear
  without one.
- Docs: https://tahoe.workonward.com/developers
- OpenAPI: https://tahoe.workonward.com/api/partner/v1/openapi.json generated
  from the running service. If it and the prose disagree, it wins.

## Rule 1: the API is read-only

Every data endpoint is a GET. The only two POSTs are
`/people/resolve:batch` and `/pool/search`, and both are reads whose input is
too sensitive for a query string. There is NO endpoint that creates, updates or
deletes anything in Tahoe.

NEVER write code that attempts to POST, PUT, PATCH or DELETE a Tahoe resource.
If the user asks for write access, tell them it does not exist rather than
inventing an endpoint.

## Rule 2: start with GET /me

Before writing integration logic, call `/me`. It returns the credential's
scopes, which workspaces it can reach, whether it must name one
(`workspace_id_required`), and its rate limits and personal-data budget.

If `workspace_id_required` is true, EVERY subsequent request must include
`?workspace_id=wsp_...`. Omitting it is `400 workspace_id_required`. There is
no default and no implicit "all".

## Rule 3: handles, not ids

Objects are addressed by opaque prefixed handles: `wsp_` workspace, `usr_`
user, `job_` job, `app_` application, `apl_` applicant, `cnd_` sourced
profile, `pool_` pool profile, `per_` person, `prj_` project, `lst_` list,
`stg_` pipeline stage, `evt_` event, `res_` resume, `mem_` list membership,
`cur_` cursor.

Handles are stable, opaque and safe to store as a foreign key.
NEVER construct, parse, increment or compare handles for ordering. A malformed
handle returns 404, deliberately indistinguishable from one that belongs to
another workspace.

## Rule 4: pagination — stop on next_cursor, never on a short page

List responses are `{object, data, has_more, next_cursor}`.
`?limit=` defaults to 25, max 100.

```python
cursor = None
while True:
    params = {**filters, "limit": 100}
    if cursor:
        params["cursor"] = cursor
    body = get("/jobs", params).json()
    for row in body["data"]:
        handle(row)
    cursor = body["next_cursor"]
    if not cursor:
        break
```

ALWAYS terminate on `next_cursor is None`.
NEVER terminate on `len(data) < limit` — scope, visibility and paywall
filtering run after the database query, so a page can return 12 rows when you
asked for 100 and still have thousands behind it.

Cursors are signed and bound to the endpoint, the exact filter set and the
credential. Send the IDENTICAL filters on every page and add only `cursor`.
Changing a filter mid-loop is `400 invalid_cursor`. NEVER decode or modify a
cursor.

Paging depth is capped at 10,000 rows (`400 result_window_exceeded`). Use
`?updated_after=` or the change feed instead of paging deeper.

## Rule 5: the change feed, and its one trap

`GET /events` is an ordered log, OLDEST FIRST. Resume with
`?after=<sequence>`. Filter with `?type=` (singular).

`sequence` is strictly increasing but NOT CONTIGUOUS for you. The log is
shared; your credential sees only what its scopes admit, so the missing numbers
are other credentials' events, not lost ones.

NEVER expect sequence + 1. NEVER treat a gap as data loss or trigger a re-read
because of one.

`next_cursor` is always null on this endpoint. The resumption token is
`next_after`.

Save the watermark AFTER durably handling a page, never before. Delivery is
at-least-once, so deduplicate on the event `id` and make every handler
idempotent.

Event payloads are THIN identifiers plus a few scalars. Re-read the resource
by handle when handling the event, so what you store reflects your current
scopes. `data.previous_attributes` names the fields that changed and is the
one part of the payload worth branching on directly.

When cold-starting: take the event watermark BEFORE the backfill. Taking it
after loses every change made during the backfill. Taking it before replays
some, which is free if your writes are upserts.

## Rule 6: the restricted contract — do not cache a lie

For any field:

- key absent, and not in `restricted` Tahoe does not have this value
- key present and null / empty array Tahoe has it and it is empty
- key absent AND named in `restricted` Tahoe has it and is withholding it

Reasons in `restricted_reason`:
- `scope_required:<scope>` your key lacks that scope
- `paywalled` the workspace has not purchased it
- `filtered:not_in_current_form` retired, consent and equal-opportunity
  form answers are never returned
- `never_exposed:consent_scope` no scope will ever unlock it

NEVER store a restricted field as null or empty in your own database. Store
"unknown". Writing null records "this person has no phone number" when the truth
was "you were not allowed to see it", and nothing will ever tell you to look
again.

`unsubscribed` is NEVER gated by scope. If it is true, do not contact that
person, in any product.

## Rule 7: identity is exact or probable

`GET /people/resolve?email=` | `?linkedin_url=` | `?coresignal_id=` exactly
one. A person is a DERIVED index (`"authoritative": false`); it points at
concrete profiles and merges nothing.

- LinkedIn URL and provider id `match.confidence: "exact"`
- email `match.confidence: "probable"`

A probable match appears in `profiles` but never contributes a VALUE to a union
endpoint, so the graph cannot write one person's phone number onto another's.

ALWAYS store `canonical_id`, not `id` resolving the same human by email and
by LinkedIn URL gives different `id` values and one `canonical_id`. Handle
`person.canonical_id_changed`, or your key silently stops matching.

A role address (`[email protected]`) is refused with `400 not_an_identity`. Use
`POST /people/resolve:batch` for up to 100 at a time; unmatched inputs come
back in order as `matched: false` rows rather than being dropped, so a
positional join is safe.

## Rule 8: errors

Every error is `{"detail": {code, type, message, param, doc_url,
required_scope, retry_after_seconds, request_id}}`.

Branch on `code`. Fall back to `type`. NEVER parse `message`.

Retry `429` and `500`. NEVER retry 400, 401, 403, 404 or 413 the answer will
not change, and a loop on a 401 is indistinguishable from credential stuffing.

**A 429 is not always a rate limit.** Check `type`:
- `type: "rate_limit"` slow down, honour `Retry-After`
- `type: "unavailable"` a dependency blinked; your rate is fine. Retry after
  a short pause. Codes: `partner_api_disabled`, `limiter_unavailable`,
  `datastore_unavailable`, `quota_unavailable`, `audit_unavailable`,
  `pool_search_unavailable`.

`Retry-After` is authoritative. On `personal_data_quota_exceeded` it can be
hours, because that budget is daily.

ALWAYS log `request_id`.

## Rule 9: personal data is metered and audited

Four scopes consume a daily budget (default 5,000/day), one unit per record:
`contact:read`, `contact:phone:read`, `resume:raw_text:read`,
`resume:download`. Each also writes an audit row naming the key, the record and
the field. Nothing else consumes it paging jobs and applications is free
against this meter.

So: fetch contact details and resumes LAZILY, when a human is about to use
them. NEVER hydrate every candidate's phone number at sync time. Watch
`personal_data_reads_used_today` from `/me` during a backfill and stop
deliberately rather than crashing into the wall mid-run.

Rate limit tiers, per credential per minute: sustained 600, expensive 60
(`/pool/search`, `/people/resolve:batch`, `/analytics/hiring-funnel`),
download 30 (resume and attachment content).

There is no `RateLimit-Remaining` header. Do not steer by one — an absent
header parsed as zero will stall the integration.

## Rule 10: deletion and erasure are obligations

Deletions arrive ONLY as events. A deleted row does not appear in a list, so
`?updated_after=` cannot tell you it went away. If you store anything, you MUST
consume the change feed or webhooks.

- `*.deleted` / `list_membership.removed` → delete your copy
- `applicant.unsubscribed` → stop contacting; keep the record
- `data_subject.suppression_applied` → delete your copy within 7 days
  (`deadline_at`) and stop processing

`GET /erasures` is the dedicated feed, readable with only `events:read` — the
weakest scope, so narrowing your other scopes never stops you hearing it.

NEVER wait for `erasure_completed`. That is Tahoe confirming its own
destruction, which can be YEARS later because of statutory retention
(`destruction_pending: true`). Your obligation attaches to the suppression.

NEVER filter the erasure feed by workspace. A pool suppression has
`workspace_id: null` and obliges every holder of that person's data.

Keep a permanent suppression list of handles and check it on every write.
NEVER put a TTL on it expiring it reinstates everyone who ever objected.

## Rule 11: what you may do with the data

`provenance.licence.redistributable` is computed and ALWAYS false, on every
person-shaped resource. Every population is either provider-derived or was
submitted to one employer for one role.

Use it inside your customer's hiring workflow. NEVER build a public directory,
resell it, feed it to a third party, or put it into a product other people
search. Do not join aggregates against your own data to re-identify individuals.

Keep applicants and sourced profiles distinguishable in your store — carry
`provenance.origin`. Applicants applied and saw a notice
(`consent.basis: candidate_submitted`); sourced profiles did not
(`legitimate_interest_sourcing`, `candidate_facing_notice: false`).

## Rule 12: never exposed

No scope returns any of this, so do not model fields for it: EEO and diversity
answers; voice-screening transcripts, recordings or decrypted answers; the
LinkedIn profile capture (you get `has_linkedin_capture` only); bearer
capabilities such as status tokens or storage keys; account secrets and
permission maps; search prompts and saved searches; billing, credits and Stripe;
recruiter notes beyond `applications:internal:read`; any other tenant.

Search EXECUTION is also not exposed — it spends the customer's credits. Use
`POST /pool/search`, which reads profiles Tahoe already holds and spends
nothing.

## Rule 13: resumes follow the product's paywall

`resume_access` carries `state`, `can_view`, `can_download`,
`unlock_credits` and `state_changes_at`. It is TIME-based as well as
purchase-based: an application walks `open → view_only → locked` on a clock, so
a resume readable last week can be locked today with nothing changing at your
end.

Check `can_view` / `can_download` on every access. Do not cache an
entitlement. Subscribe to `resume.access_changed`.

`GET /applications/{handle}/resume/download` returns JSON containing a
short-lived `url` plus `expires_in` NOT a 302, so your credential is never
forwarded to the storage host. Fetch it promptly and never persist the URL.
`403 resume_locked` means the workspace has not purchased access; that is not
something your code can fix.

## Rule 14: webhooks — verify before you parse

Header `Tahoe-Signature: t=<unix>,v1=<hex>[,v1=<hex>]`, HMAC-SHA256 over the
literal string `{t}.{raw_body}`, 300-second tolerance.

- Sign the RAW BYTES. A re-serialised JSON object has different key order and
  whitespace and will never verify.
- Compare with a constant-time function, never `==`.
- TWO `v1` values appear during a secret rotation. Accept if EITHER verifies,
  or every rotation is an outage.
- Reject a timestamp outside the tolerance — that is the replay defence.
- Return 2xx as soon as the event is durably queued; do the work after.
- Redirects are NOT followed; a 301/302 is a failure.
- Retries: 10s, 30s, 2m, 10m, 1h, 6h, 12h, 24h, 24h, then abandoned. Repeated
  failure disables the endpoint.

Keep the change feed as the backstop. A consumer that can only be pushed to has
no way to recover from a gap.

## Rule 15: Sign in with Tahoe (OIDC)

Issuer: `https://tahoe.workonward.com/api/partner/v1`  note the PATH
component; a client that assumes the issuer is a bare origin fails `iss`
validation. Configure from
`/.well-known/openid-configuration` rather than hard-coding endpoints.

Authorization code + PKCE, `S256` ONLY (`plain` is refused).
`code` and `refresh_token` are the only grants — there is no
`client_credentials`; use an API key for machine-to-machine.

- Redirect URIs match EXACTLY. No wildcards, no trailing-slash forgiveness.
- An unregistered `redirect_uri` gets a 400 at Tahoe's origin, NOT a redirect
  carrying an error — bouncing an error to an unverified URI is an open redirect.
- `sub` is PAIRWISE: the same Tahoe user has a different `sub` in every app.
  Fine as your own primary key; meaningless to anyone else.
- JWKS holds two keys during a rotation. Select by `kid`, never `keys[0]`.
- Refresh tokens ROTATE. Replaying a used one revokes the entire token family
  and forces re-authentication — so serialise refreshes per user and persist the
  new token before using it. NEVER retry a refresh with the old token after a
  timeout.
- A failed refresh means re-authenticate, not retry: the user may have
  disconnected your app.
- A delegated access token's reach is the INTERSECTION of what your app asked
  for and what that human can see.

## The four mistakes that pass code review

1. **Terminating pagination on a short page.** Silently truncates every sync.
   Only `next_cursor is None` ends the loop.
2. **Expecting event sequence numbers to be contiguous.** The gaps are other
   credentials' events. Treating one as data loss produces an infinite re-read.
3. **Treating every 429 as a rate limit.** Check `type`;
   `unavailable` means a dependency blinked, and backing off your whole
   integration is the wrong response.
4. **Caching a `restricted` field as empty.** Records "no data" when the truth
   was "not permitted", and nothing ever corrects it.

## Before you say the integration is done

- `/me` called, scopes and `workspace_id_required` handled
- pagination terminates on `next_cursor`, filters identical across pages
- change feed consumed, watermark saved after the work, handlers idempotent
- deletion, unsubscribe and suppression handled; suppression list has no TTL
- `restricted` distinguished from empty everywhere it is stored
- contact and resume reads are lazy, and the daily budget is watched
- `request_id` logged on every failure
- 429 branches on `type`; `Retry-After` honoured