PlanCover Docs
Reference

Tool authorisation

What a tool declares, the chain every call passes through, and the closed set of ways one can fail.

A tool call is not a function call. It is a request from an untrusted party that has to survive five checks before anything happens.

The framework has no opinion about any of those checks, so a tool is not just a LangChain tool. It is a ToolSpec: the callable plus the facts that decide whether it may run at all.

What a tool declares

FieldTypeWhy it exists
toolBaseToolThe callable, its description and its argument schema
riskread · write · sendWhat a call can do, which is what decides confirmation
rolesfrozenset[Role]Who may call it — both the binding filter and the executor check
idempotentboolRetry-safety, declared by semantics rather than inferred
confirmation_overridebool | NoneRaises the bar above the risk tier. Can never lower it
ownership(auth, args) -> boolMay this caller touch this resource
requires_credentialboolTrue for anything that calls PlanCover
provides_evidenceboolTrue for a tool whose whole job is returning passages
timeout_secondsfloat | NonePer-tool ceiling; falls back to TOOL_TIMEOUT_SECONDS
summarise(args) -> strOne sentence a human can approve or reject

The argument schema is taken from the tool's own typed signature, minus the injected runtime parameter — the model sees the business arguments and nothing else.

The chain

app/tools/executor.py
model proposes a tool call
  │
  ├─ 0. known tool?            unregistered names never reach a handler
  ├─ 1. role permission        this role may use this tool at all
  ├─ 2. argument validation    arguments match the declared Pydantic shape
  ├─ 3. credential present?    a signed-out caller is told that, not "not found"
  ├─ 4. resource ownership     this user may touch this portal / claim
  ├─ 5. business rules         does this need a human, and is one already waiting
  │
  ▼
timeout ─► call ─► sanitize ─► audit ─► the model sees a safe result

The credential check sits third rather than last, and the order is load-bearing. AuthContext.portal_refs is derived from the credential, so a caller without one owns nothing — and asking about ownership first answers "that record does not exist" to somebody whose real problem is that they are signed out. It is ahead of the confirmation gate for the same reason: there is no sense asking a human to approve an action that cannot run. Only the presence of a credential is checked here; the token itself is exchanged inside the service when the call runs.

Two invariants hold at every step.

It never raises into the graph

Every failure becomes a typed ToolFailure with a stable code, including an unexpected exception, which becomes tool_failed. A refusal is still an executor decision, so it is still streamed as a tool.started / tool.completed pair and still written to tool_audit.

A confirmation-requiring call is never executed in the same pass as anything else. Step 5 short-circuits it into a pending_action; the confirm node interrupts and only then executes.

Failure codes

A closed set. The answer model, the event contract and the audit table all key off these exact strings, so adding one is a contract change.

CodeRaised whenReported as
unknown_toolThe name is not in the registryThe assistant does not have that capability
forbiddenThe caller's role may not use this toolTheir role cannot do this
invalid_argumentsPydantic validation failedWhich field is wrong, so the model can correct it
not_foundThe ownership check said noThe record does not exist, or is not theirs
confirmation_requiredAnother action is already awaiting approvalHandle that one first
credential_unavailableThe caller has no stored PlanCover sessionSign in again
upstream_unavailableA dependency failed rather than returned nothingIt is temporarily unavailable
upstream_timeoutThe call exceeded its timeoutIt took too long, and nothing was changed
tool_failedAnything elseIt failed, and nothing was changed

not_found, not forbidden

A failed ownership check reports not_found on purpose. Whether a claim or a portal exists is itself information the caller is not entitled to, and forbidden would confirm it.

Two layers of RBAC

Both exist, and they are not redundant.

The model is only shown what it may use

tools_for(role) filters the toolset before bind_tools, so a tool the caller may not use is invisible rather than refused. Not offering a capability is stronger than declining it after the fact, and it keeps a refusal out of the conversation entirely.

The executor re-checks the role anyway

"Not exposed" is not an authorisation control. A model can name a tool it was never shown — from its own priors, from a retrieved document, from an earlier turn — and the interceptor, not the binding, is what stops that call.

Risk tiers and confirmation

RiskMeaningNeeds a human
readNo side effectNo
writeCreates or changes a record upstreamYes
sendMessages a real person — WhatsApp, email, SMS. Not undoableYes

A tool may raise the bar for itself with confirmation_override=True — a bulk export, say. It can never lower it: a write stays confirmable whatever it declares. The confirmation is a deterministic step in the graph, not a sentence the model decides to say.

Retry-safety is a semantic property

Never infer safety from the HTTP verb

PlanCover sends a WhatsApp message with a GET, and deletes an employee with a GET. Verb-based reasoning classifies both as safe reads.

Retry-safety is declared per tool and holds only for idempotent reads. There are no idempotency keys on the PlanCover API, so a retried write can duplicate a record with nothing to deduplicate it against. The same rule shapes the graph: RetryPolicy sits on the idempotent nodes only, never on execute_tools.

When a call times out, the failure it reports is marked retryable if and only if the tool declared itself idempotent. Retry-safety is a property of the tool, never of the failure.

Credentials

The model proposes:

get_plan_details(portal_ref="PRT-40122")

It never proposes, and never sees, an access_token argument. AuthContext carries an opaque credential_ref, which the service exchanges for a token inside the call itself. This is why a prompt injection in a retrieved document cannot exfiltrate a token: the token was never in the model's reach.

A tool that declares requires_credential refuses with credential_unavailable when the caller has none — it must never become an unauthenticated upstream request — and the executor puts an auth.required frame on the stream, which is what raises the client's sign-in card. All thirteen PlanCover read tools set the flag; retrieval and the callback fixture do not.

That refusal is now the fallback, reached only on the gather path. A planned member question never gets that far signed out: plan_turn decides first, and auth_gate pauses the turn on an interrupt that records no mobile number, OTP, identifier or question text. The client shows a resumable sign-in card, the member signs in over /auth/*, and POST /chat/resume carries the same turn on (see Streaming).

Member reads chosen by code

For a planned member or mixed turn the model does not choose the reads. tools/member_reads.py maps the plan's topics to calls, fills each portal_ref from the caller's own portal_refs (at most three portals and six reads), and prefetch puts them on the graph as an internal tool-call message — so they go through this same executor, with every check above, the audit row and the tool.started / tool.completed pair.

TopicReads
coverget_covered_benefits, get_plan_details (+ the planned searches)
familyget_insured_members (+ the planned searches)
policyget_policy_details, get_policy_validity
claimslist_claims; get_claim_status for a named claim, or one follow-up when there is exactly one
ecard / documentsget_ecard / get_policy_documents
hospitalsfind_network_hospitals, only when a pin code or city was given
companyget_company_details
(no portal on record)list_my_policies

A mixed turn adds search_policy_knowledge in the same step. Only read-only, credentialed reads can be planned; a test checks every name against the registry.

The sanitizer

Everything a tool returns passes through one function before the model, the wire or the audit table sees it. Three jobs, in order:

Redact

Using the same redact_value the logger uses, so a tool result, a log line and a trace payload are redacted by one implementation. PlanCover returns mobile numbers and email addresses on ordinary reads.

Strip document URLs

Every e-card, PDF and document endpoint returns one, and tool URLs are forbidden in prompts, state, logs and traces. The key is dropped whole rather than masked: leaving "document_url": "[url removed]" tells the model a link exists and invites it to ask. The document_id beside it survives, and the link is resolved at emit time, outside the graph.

Truncate

GET /blacklisted-hospitals is 749 KB and unpaginated. Without TOOL_MAX_RESULT_CHARS, one tool call can consume the whole context window. The cut says so, so the model knows it saw a part — and the summary line is never truncated away, so a reader of the first line still gets a true statement.

Tool arguments get the same treatment before they reach the wire or the audit row. Arguments come from the model, so they hold no credential by construction — but a member who typed their mobile number into a free-text field put one there, and this is where it stops.

The tools that exist today

Fifteen are registered: thirteen PlanCover reads, the retrieval tool, and one framework fixture. The thirteen are curated rather than generated — of the API's 161 endpoints only 89 are verified working, 29 verifiably fail (the 16 /cases/* and /case-agent/* routes among them, which reject our token as a realm failure) and 42 have never been executed, and a model choosing from 161 tools chooses worse than one choosing from thirteen.

Every PlanCover read shares one shape, so the tables below record only what differs: read risk, every role, idempotent, requires_credential, no confirmation. Ownership is owns_portal wherever there is a portal_ref — a portal the caller does not hold reports not_found. The claim tools need no ownership check: PlanCover scopes claims to the member's own token upstream.

Policies

list_my_policies is the entry point. Every other policy tool needs a portal_ref, and this is the only place one comes from.

ToolArgumentsAnswers
list_my_policies—Which policies cover this member
get_policy_detailsportal_refPolicy number, insurer, start and end dates
get_policy_validityportal_ref"Am I still covered?"
get_plan_detailsportal_refSum insured, top-up, enhanced cover — how much, not what
get_covered_benefitsportal_refWhat this plan covers and excludes, live rather than from the corpus
get_insured_membersportal_refThe member, and any dependants
get_company_details—The company this group policy belongs to

get_covered_benefits is preferred over the corpus whenever the member is asking about their cover; search_policy_knowledge still owns conditions, waiting periods and procedure, which the live benefit list does not carry.

Claims

ToolArgumentsAnswers
list_claims—Every claim on the account, newest first — and the claim number the other two need
get_claim_detailclaim_numberOne claim in full: amounts, hospital, dates, who holds it
get_claim_statusclaim_refThe tracking stages, for "why is my claim still pending"

Three tools rather than one because the API answers the three questions in three different shapes. Either a claim number or an id works for both lookups — the two upstream endpoints want different ones and the service translates. Neither returns a settlement date, so the model is told not to predict one.

Documents

ToolArgumentsAnswers
get_ecardportal_refThe member's health e-card
get_policy_documentsportal_refInsurer document, TPA and schedule

A handle, never a link

PlanCover's own /storage/… document URLs need no credential at all — verified: HTTP 200, no token, for a PDF carrying the member's name, employee id, policy number and insurer. So these two return an opaque handle instead — the URL encrypted under ENCRYPTION_KEY, bound to this member, expiring in minutes — and GET /documents/{handle} re-checks the caller before streaming the bytes. The model, the transcript, the checkpoint and the logs never see a link, and the model is told to name the document and leave the handle out of its reply.

Network hospitals

ToolArgumentsAnswers
find_network_hospitalsportal_ref, pin_code?, city?Cashless network hospitals near the member, nearest first

One of pin_code or city is required, and a pin code must be exactly six digits; either mistake is answered with invalid_arguments rather than a search. Upstream returns 250 records (~84 KB) filtered by region whatever it is asked, so the service re-ranks — exact pin, then city, then district — and cuts to ten. The cap is not tidiness: TOOL_MAX_RESULT_CHARS is 4000, and 84 KB truncates mid-record into a corrupted list of half-addresses. The ten are a regional list rather than an exhaustive one, which is what the model must say when a hospital the member named is not among them.

Retrieval, and the callback fixture

ToolArgumentsRiskRoles
search_policy_knowledgequeryreadAll
request_callbackreason, preferred_window?sendEmployee, Company HR

search_policy_knowledge is the retrieval tool, and the only spec that sets provides_evidence. On a planned turn plan_turn's router writes its queries and prefetch runs them; on the gather path the planning model calls it itself. It returns its passages as evidence rather than as text in the tool reply, so the context builder owns the budget and finalize owns the citations. A dead index is reported as upstream_unavailable with an explicit instruction not to answer from memory. It needs no credential: the corpus is the same for every caller. It takes no filters either — a model given document_type and policy_ref narrowed searches nobody asked it to narrow — and the caller's company scope is built from AuthContext, never from the model.

request_callback is a fixture, not product

request_callback makes no network call. It exists to exercise interrupt, resume and audit on a confirmed send, and it is still the only tool above the read tier, so deleting it would leave nodes/confirm.py, the confirmations table and POST /chat/confirm with nothing exercising them. It goes when the first real write tool lands. Its read sibling echo_policy_summary is gone: it returned invented policy data beside get_policy_details.

Role mappings are provisional across the whole registry — every PlanCover read is open to every role — because the production RBAC matrix is still an open question in docs/PLAN.md §27.

The audit trail

One row per executor decision, including the ones that refused to execute, unique on (conversation_id, tool_call_id). The uniqueness is not decoration: resuming an interrupt re-runs the whole node, and without it an approved write would audit twice and the trail would claim two actions where one happened.

A failed audit write degrades to a log line rather than failing the member's turn.

On this page