Metadata-Version: 2.4
Name: librarian-a2a
Version: 0.19.1
Summary: An A2A agent that learns APIs on demand and gives other agents safe, audited access: reads within policy, writes with human approval.
Keywords: a2a,agents,api,openapi,llm,pydantic-ai
Author: kultar
Author-email: kultar <kultar@kultar.net>
License-Expression: Apache-2.0
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Dist: a2a-sdk[http-server,sqlite]>=1.2.2
Requires-Dist: httpx>=0.28.1
Requires-Dist: mcp>=2.3
Requires-Dist: opentelemetry-api>=1.33
Requires-Dist: pydantic>=2.14.0
Requires-Dist: pydantic-ai-slim[anthropic,openai]>=2.55.0
Requires-Dist: pydantic-settings>=2.15.0
Requires-Dist: pyjwt[crypto]>=2.9
Requires-Dist: python-multipart>=0.0.32
Requires-Dist: pyyaml>=6.0.3
Requires-Dist: typer>=0.27.3
Requires-Dist: uvicorn>=0.54.0
Requires-Dist: boto3>=1.35 ; extra == 'aws'
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.33 ; extra == 'otel'
Requires-Dist: opentelemetry-instrumentation-asgi>=0.54b0 ; extra == 'otel'
Requires-Dist: opentelemetry-sdk>=1.33 ; extra == 'otel'
Requires-Dist: a2a-sdk[postgresql]>=1.2.2 ; extra == 'postgres'
Requires-Python: >=3.12
Project-URL: Homepage, https://librarian-agent.com
Project-URL: Repository, https://github.com/kultar/librarian
Project-URL: Issues, https://github.com/kultar/librarian/issues
Project-URL: Security, https://github.com/kultar/librarian/security/policy
Provides-Extra: aws
Provides-Extra: otel
Provides-Extra: postgres
Description-Content-Type: text/markdown

<p align="center"><a href="https://librarian-agent.com/"><picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/kultar/librarian/main/site/assets/banner-dark.svg">
<img src="https://raw.githubusercontent.com/kultar/librarian/main/site/assets/banner.svg" alt="librarian: one agent that learns APIs on demand and keeps the keys" width="760">
</picture></a></p>

**An A2A agent that learns APIs on demand and gives other agents safe, audited access to them: reads freely within policy, writes only with human approval.**

Agents shouldn't each carry their own Jira token, GitHub token and API client. With librarian,
agents ask one specialist, over [A2A](https://a2a-protocol.org), for the data they need. The
librarian learns the API the first time it is needed (from its OpenAPI spec, or from its
documentation when there is no spec), asks a human for a
credential (once, out of band), and from then on serves every agent, without ever handing the
credential out.

```
 your agents ──A2A──► librarian ──(policy engine)──► Jira, GitHub, internal APIs, ...
                         │
                         ├── registry (git): what each API is and which operations are reads
                         ├── secret store: credentials (agents only ever see references)
                         └── planner (Pydantic AI, optional) ──► any model / LiteLLM
```

> Status: **alpha (v0.18)**. Expect breaking changes. Website: [librarian-agent.com](https://librarian-agent.com/).

## Why

- **One choke point.** Every call from every agent goes through one deterministic policy engine and
  lands in one audit log.
- **Credentials never travel through agents.** Secrets enter via a one-time secure page, straight
  into the secret store. Agents, messages, logs and the LLM only see references like `jira/readonly`.
- **Learn once, reuse everywhere.** The first agent that needs an API triggers onboarding; every
  agent after that benefits.
- **The LLM proposes, code decides.** The optional planner can suggest any call, but only the rule
  engine (no LLM) can approve it. No prompt can talk its way into a write.
- **Access is held three ways, and none of them by an agent.** A shared read-only key an
  approver adds once; a separate write credential, used only after a person approves the write;
  or, for APIs where what you see depends on who you are, each person's own OAuth sign-in,
  done once in their browser and used only for their requests (see
  [Acting as the person](#acting-as-the-person-per-person-oauth)).

### What your agents save

- **Seven tools instead of hundreds.** An agent with direct access to an API carries that API's
  tool definitions in its context on every request (GitHub's spec has 1,242 operations). Through
  librarian an agent sees seven tools and asks `capabilities` for the handful it needs.
- **Learning is paid once.** Reading an API's documentation, searching for it and extracting its
  operations is the expensive model work. It runs once per API, with a model of your choice
  (`learning.model`), then serves every agent.
- **Most calls use no model at all.** `execute` with a known operation is plain code: policy
  check, HTTP call, provenance. Plain-language `fetch` costs one planner call, and a successful
  plan is saved as a recipe and replayed without the model next time.
- **Smaller answers.** Field selection and a response-size cap return the fields an agent asked
  for, not a whole payload into its context; pagination is handled on librarian's side.
- **Operations exist once.** Credentials, rotation, retries, change detection and the audit
  trail live in one place instead of in every agent.

What it does not shrink is an agent's own reasoning: librarian removes the overhead around the
call, not the thinking before and after it. The audit log records every call with its byte count
and whether a model was involved, so a week of real traffic shows the split.

## Get started

| I want to... | Go to |
|---|---|
| Try it on my laptop (10 minutes, Docker) | [docs/getting-started.md](docs/getting-started.md) |
| Run it for my team (Kubernetes, step by step) | [docs/deploy.md](docs/deploy.md) (no internet exposure needed: [network](docs/deploy.md#network-what-must-reach-what)) |
| Connect agents (LiteLLM, MCP clients, A2A) | [docs/integrations.md](docs/integrations.md) |
| Fix something | [docs/troubleshooting.md](docs/troubleshooting.md) |

```bash
pip install librarian-a2a                  # the CLI and import are `librarian`
docker pull ghcr.io/kultar/librarian:0.19.1
```

### What happens on a request

1. An agent asks for data from an API librarian doesn't know yet.
2. librarian learns it: from its OpenAPI spec, else its documentation (found by itself if
   needed), else the model's knowledge. The API is usable for reads at once, and a pull request
   is opened on your registry repo for a person to review.
3. If the API needs a key, the agent's task waits and approvers get a Slack card. Someone follows
   the link, signs in and adds the key (it goes straight to your secret store), and the task
   carries on by itself. If the API needs the *person's own* sign-in (OAuth), the person
   behind the agent gets the link instead, signs in to the provider once, and every later
   request for them carries their own token.
4. Writes wait for a person's approval every time, unless a narrow, expiring grant covers them
   or an approver chose automatic approval for that operation (capped per day, never DELETE).
   A write nobody has enabled yet is not refused silently: approvers are asked to enable it,
   from Slack or the console, and their decision is merged into the registry at once.

Nothing is set up per API in advance: the registry fills itself as agents need things.

## Skills (A2A)

| Skill | What it does | LLM? |
|---|---|---|
| `execute` | Run one read operation: `{"skill":"execute","api":"jira","operation":"getIssue","args":{"issueIdOrKey":"ABC-1"}}`. Optional `select` (dotted paths, e.g. `["fields.summary"]`) and `max_pages` | No |
| `capabilities` | List APIs and read operations: `{"skill":"capabilities","query":"issues"}` | No |
| `onboard` | Learn an API: `{"skill":"onboard","api":"jira","spec_url":"https://..."}`, `"base_url"` to auto-discover, or `"docs_url"` + `"base_url"` when there is no spec | No |
| `write` | Change data, with human approval or a grant: `{"skill":"write","api":"jira","operation":"addComment","args":{...},"body":{...},"reason":"why"}` | No |
| `fetch` | Plain language: *"open bugs assigned to the payments team"* (only when a model is configured) | Yes |

The same skills are available as **MCP tools** at `/mcp` (Streamable HTTP, same caller keys):
`capabilities`, `execute`, `fetch`, `write`, `write_status`, `enable_status`, `onboard`. MCP calls
never wait on a human: writes return `pending_approval` and librarian finishes them once
approved, or `write_not_enabled` when nobody has enabled that operation yet (approvers are
asked; `enable_status` says where the request stands). librarian
works behind the **LiteLLM** gateway as both an MCP server and an A2A agent
([docs/integrations.md](docs/integrations.md#litellm-agent-gateway)).

Results are A2A artifacts with the data plus **provenance**: which API and operation, the URL
(secrets redacted), when, for which calling agent and principal, and which credential reference.

When a credential is missing, the task enters A2A's `TASK_STATE_AUTH_REQUIRED`, approvers are
notified (Slack by default), and the task **resumes on its own** once someone adds the key. The
caller polls or subscribes to the task as usual.

## Configuration

Settings come from a YAML file (`--config`) and/or `LIBRARIAN_*` environment variables (env wins;
nested keys use `__`, e.g. `LIBRARIAN_NOTIFIER__SLACK__BOT_TOKEN`). See
[`examples/librarian.yaml`](examples/librarian.yaml) for every option.

**Models / LiteLLM.** `fetch` needs a model. Use any Pydantic AI model string
(`model: anthropic:claude-sonnet-5-5`, with the provider's usual API-key env var), or point at any
OpenAI-compatible gateway such as LiteLLM:

```yaml
model: claude-sonnet-5-5            # the model name your gateway serves
model_base_url: http://litellm:4000/v1
model_api_key: sk-litellm-...       # or LIBRARIAN_MODEL_API_KEY
```

**Secrets.** Choose a backend with `secrets_backend`:

- `file` (default): one `0600` file per secret, good locally or on a mounted volume.
- `aws`: AWS Secrets Manager (`uv sync --extra aws`). `jira/readonly` maps to the secret
  `<prefix>jira/readonly`; append `#key` to read one key of a JSON secret
  (`service/jira#api_token`). Values are cached briefly (`aws_secrets.cache_seconds`).

Environment variables are always checked last (`jira/readonly` → `LIBRARIAN_SECRET_JIRA_READONLY`).
Internal secrets (caller keys, the link-signing key) live under the reserved `librarian/` prefix.

**Internal APIs.** URLs that agents supply (onboarding) and the entries learned from them may
only reach public addresses, so no agent can point librarian at your network. List the internal
domains agents may learn on demand:

```yaml
internal_hosts: ["*.internal.example.com", "jira.corp"]   # or LIBRARIAN_INTERNAL_HOSTS='["*.internal.example.com"]'
```

A matching host may resolve to private addresses (never loopback or link-local, i.e. cloud
metadata), with the same DNS pinning as public hosts; everything else stays public-only. The
review PR and the audit log say when an API was learned under one of these patterns.
`allow_private_networks: true` allows everything and is for a laptop.

**State.** Credential requests, approvals, A2A tasks and APIs learned on demand are stored in
SQLite by default (`.librarian/librarian.db`), so they survive restarts. For several replicas use
Postgres (`database_url: postgresql+asyncpg://...`, the `postgres` extra; in the image) and
`secrets_backend: aws`: then every replica shares everything, an API learned through one is usable
through the others at once, and pods need no volume. Set `replicas` (the Helm chart does) and
librarian refuses to start with per-replica state. Rate limits are counted in the database
too, so every replica enforces the same limit together.

**Writes and plain-language requests.** `fetch_timeout_seconds` (100) stops a plain-language
request and returns what it has, marked partial, before the caller's own timeout;
`max_calls_per_fetch` (8) caps its API calls. `publish.merge_console_decisions` (true) merges a
decision made in the console or on an enable page as the App at once; off, a person merges on
GitHub.

**Caller authentication.** List agents and the secret holding each one's key:

```yaml
callers:
  - agent: release-notes-agent
    key_ref: librarian/callers/release-notes-agent   # internal secrets live under librarian/
    apis: ["jira", "github-*"]                       # glob patterns; default ["*"]
    can_onboard: false                               # may this agent teach new APIs?
```

An agent only sees and uses the APIs its patterns allow, in `capabilities`, `execute` and `fetch`.

With no callers configured the server is open: fine on a laptop, never in a shared deployment.

## Observability

Set `otel_enabled: true` (install the `otel` extra; the Docker image has it) and the standard
`OTEL_EXPORTER_OTLP_ENDPOINT`. You get:

- **Traces**: `librarian.skill` and `librarian.call` spans (plus the A2A SDK's and Pydantic AI's
  model-call spans). A calling agent's `traceparent` is honoured, so its trace continues through
  the librarian. Trace headers are **never** forwarded to third-party APIs.
- **Metrics**: `librarian.api.calls` (by API, operation, caller, status class),
  `librarian.api.call.duration`, `librarian.policy.denials`, `librarian.credential.requests`.

The JSONL audit log is separate and always on.

## Big APIs

- **Operation search.** Requests are matched to operations with BM25 ranking over operation ids
  (split into words: `getIssueWatchers` -> issue, watch), tags, paths and summaries. On Jira's
  620-operation API, "who is watching an issue" finds `getIssueWatchers` first.
- **Glossary.** Registry entries can declare `discovery` reads that teach instance-specific
  names, e.g. Jira's `/field` maps `customfield_10020` to "Sprint". They run after a credential
  is approved (or `librarian discover <api>`), and the planner sees the relevant entries.
- **Recipes.** A successful plain-language request is saved as a template: "Is PAY-1 open?"
  becomes `Is {p0} open`, and "Is PAY-7 open?" later replays the same calls **without the LLM**
  (still policy-checked, with the caller's permissions). Plans whose later calls use values from
  earlier results are never saved. `librarian recipes list|delete`; `recipes_enabled: false`
  turns it off.

## APIs without a spec, and APIs that change

**Learning without a spec** (needs `model`; set `learning.model` to use a stronger model for
learning only, since that happens once per API while `fetch` runs on every request;
`fallback_model` is tried once when a model fails or declines a request). librarian learns
from, in order of preference:

1. **An OpenAPI spec**: given, found at a well-known path, or linked from the docs.
2. **Documentation pages**: the model reads the page and related pages on the same site and
   lists the operations, each with a verbatim quote. librarian checks every quote against the
   fetched page and drops operations it can't find there. With only a `base_url`, librarian
   **finds the docs itself**: the usual places first (`/llms.txt`, `/docs`, `docs.<domain>`,
   links on the homepage, one click further), then a **web search** if configured.
3. **The model's own knowledge** (nothing found): reads only, for well-known APIs.

Search results are untrusted (anyone can publish "Acme API docs"), so sources are ranked:
the API's own domain first, then well-known docs hosts (GitHub, ReadMe, GitBook, ...);
other sites are skipped unless `learning.search.allow_third_party: true`. The review PR says
how the docs were found and how far they are trusted.

```yaml
learning:
  search:
    provider: model        # off | model | searxng | brave
    # url: https://searx.internal          (searxng)
    # api_key: via LIBRARIAN_LEARNING__SEARCH__API_KEY   (brave)
```

`provider: model` uses the model's own web search. Through a gateway such as LiteLLM that
needs the Anthropic API format (`model_api: anthropic`); with the OpenAI formats the search is
refused or silently dropped by the gateway, and librarian reports that instead of guessing.

**Specs that declare no authentication** are common even for APIs that need a key (GitHub's
does). librarian then asks the model what the API really uses and writes it into the proposed
entry, for approval. Approvers never edit YAML: the review pull request is approve or deny, and
anything that needs a person's choice (the authentication, when the model can't tell) is a
choice in the console. If such an API answers 401 later, librarian infers the authentication
then and asks for a key; a verified entry gets an update proposal instead.

Documentation is untrusted, so the model never decides anything that matters: it has no tools;
the API host is always the `base_url` you gave (never one from the docs); read vs write is decided
by HTTP method; the auth *type* may come from the docs but where the key is stored never does.
Operations from docs or model knowledge are **unconfirmed** until a real call succeeds (callers
see a warning). One that keeps answering 404 is **suspended** and its owners are told.

```bash
uv run librarian learn dogs --docs-url https://dog.ceo/dog-api/documentation/ \
    --base-url https://dog.ceo/api --name "Dog CEO"
```

**Noticing changes.** Every `drift.recheck_hours` (default 24), one replica re-reads each entry's
spec or docs. A new model read only happens when the docs' text actually changed.

| Change | Provisional entry | Verified entry |
|---|---|---|
| New operations, optional parameters | updated, re-proposed | proposed for review |
| Removed operation, new required parameter, moved path | updated, re-proposed | callers warned; writes **suspended**; proposed |
| HTTP method changed (a read may now change data) | updated, re-proposed | **suspended**; proposed |

Verified entries are never changed automatically. The proposal PR keeps reviewed decisions
(writes enabled, reviewed reads, pagination); merging it lifts the suspensions it addresses.

Calls also watch for change: `Deprecation`/`Sunset` headers, repeated 404/405/410 responses, and
responses that lose most of the fields seen before are reported to the API's owners (once), and
callers see a warning. `librarian registry recheck [api]` runs a check now;
`librarian registry clear <api> <operation>` lifts a suspension after checking by hand.

## Acting as the person (per-person OAuth)

Some APIs have no credential that is right for everyone: what Google Drive or Jira returns
depends on who is asking. For those, librarian is the OAuth client and each person signs in
once:

```yaml
# in the registry entry (verified entries only)
auth:
  type: oauth2_user
  authorization_url: https://accounts.example.com/o/oauth2/auth
  token_url: https://oauth2.example.com/token
  scopes: [files.read]
  client_id: "<the OAuth client you registered for librarian>"
  client_secret_ref: drive/oauth-client          # in the secret store; omit for a public client
  extra_authorize_params: {access_type: offline, prompt: consent}   # provider extras
```

Register librarian with the provider once, with the redirect URI `<public_url>/oauth/callback`.
For GitHub, `librarian github-app create --oauth-callback <public_url>/oauth/callback` creates
an App that is also the OAuth client and saves its client secret next to the key (GitHub Apps
use the App's permissions, so `scopes` stays empty).
The first time a person's agent needs the API, that person (not the approver group) gets a
one-time link, by Slack direct message when Slack knows their address: they sign in to the
provider in their browser and librarian stores their tokens under `<api>/users/<hash>`. From
then on every request their agents make for them carries their own token, so the API enforces
their access and librarian's policy applies on top. Tokens are refreshed by librarian; a
rejected or expired sign-in simply asks the person again. Agents never see the tokens, and a
request for person B can never go out with person A's token (recipes and credentials are kept
per person). The console lists who is signed in to what and can disconnect them.

With `principals.mode: required` the person must be verified by your identity provider before
their token is used; with `off` (a laptop) the agent's claimed principal is trusted. Requests
with no person behind them are refused for these APIs. MCP callers get `consent_pending` while
the person signs in.

## Verified principals

By default the person an agent acts for (`principal`) is whatever the agent claims. Turn on
verification to require proof: the agent sends a token from your identity provider in the
`X-Principal-Token` header, and librarian checks its signature (issuer's published keys), issuer,
audience and expiry.

```yaml
principals:
  mode: required          # off | optional | required
  issuers:
    - issuer: https://acme.okta.com/oauth2/default
      audience: librarian  # tokens must be minted for librarian
      claim: email
```

- `optional`: verified when a token is present; a claimed principal without one is recorded
  but does **not** count as the requester for write approvals.
- `required`: a claimed principal without a valid token is refused. Requests with no person
  behind them (autonomous agents) still work.

Verified identities appear in provenance (`principal_verified: true`) and the audit log. How an
agent gets a token for librarian on a user's behalf is covered in
[docs/integrations.md](docs/integrations.md#verified-principals).

## Slack notifications

1. Create a Slack app from [`deploy/slack-app-manifest.yaml`](deploy/slack-app-manifest.yaml) and
   install it to your workspace.
2. Set `notifier.type: slack`, the bot token (`LIBRARIAN_NOTIFIER__SLACK__BOT_TOKEN`), a channel,
   and optionally an approver user-group ID (rendered as a real @mention).

Every API learned on demand gets a **review card**: what was learned, the authentication it
will use, anything to check, and a **Review** button that opens a signed-in page where an
approver sees the proposal, decides any operations that need it (read, write or exclude), and
**approves** (the pull request is merged as the App and the API becomes verified) or **denies**
(it is closed and the API forgotten). The card is updated with the outcome and who decided.
Approvers also get one card per API that needs a key (deduplicated, with a live "waiting"
count) and a **Review request** button that opens the secure page: *add a key*, *use an existing secret*, or *deny*. The card
updates in place and the outcome is posted in the thread. The key itself never goes through Slack.

No Slack? `notifier.type: webhook` posts JSON events you can route anywhere.

## What it looks like

<p align="center">
<img src="https://raw.githubusercontent.com/kultar/librarian/main/site/assets/screens/console-overview.png" alt="The console overview: tiles for APIs, what waits on a person, grants and signed-in people, then the waiting list and recently changed APIs" width="760"><br>
<em>The console: what librarian knows, and what waits on a person.</em>
</p>

<p align="center">
<img src="https://raw.githubusercontent.com/kultar/librarian/main/site/assets/screens/console-api.png" alt="An API page: summary, search, the operations that need attention first, then operations grouped by tag" width="760"><br>
<em>A large API: search, the operations that need a decision first, then groups by tag.</em>
</p>

<p align="center">
<img src="https://raw.githubusercontent.com/kultar/librarian/main/site/assets/screens/console-activity.png" alt="The Activity page: the last seven days in numbers (reads, writes, denied, plain-language requests and the share answered without a model), the busiest APIs, and recent events with who, where and how much" width="760"><br>
<em>Activity: what agents did, and what it cost.</em>
</p>

<p align="center">
<img src="https://raw.githubusercontent.com/kultar/librarian/main/site/assets/screens/enable-flow.png" alt="The enable page for a write flow: the five operations an agent asked for with ticks, the approval choice (each write, or automatic with a daily cap), the write credential choice, Enable, and Decline" width="760"><br>
<em>An agent wanted a write nobody had enabled: the approver enables the whole flow, chooses how writes are approved and where the write credential comes from.</em>
</p>

<table align="center"><tr>
<td><img src="https://raw.githubusercontent.com/kultar/librarian/main/site/assets/screens/review.png" alt="The review page for a learned API: the proposal, operations that need a decision, approve and merge, or deny" width="380"></td>
<td><img src="https://raw.githubusercontent.com/kultar/librarian/main/site/assets/screens/approve-write.png" alt="A write approval page: method, path, arguments and body, approve once, allow similar, or deny" width="300"></td>
</tr><tr>
<td align="center"><em>Approving a learned API, from the Slack card.</em></td>
<td align="center"><em>Approving one write: exactly what will be sent.</em></td>
</tr></table>

## The console

The pages listener's root (`/`) is a console for the people who run librarian, with pages for
the overview, the APIs (status, host, operations and where they were learned from), what is
waiting on a person (keys to add, writes to approve or enable, proposals to review, sign-ins), the
standing grants, the people signed in to per-person APIs, each person's own sign-ins, and
**activity**: what agents did in the last seven days (reads, writes, denials, plain-language
requests answered from a recipe versus by the model, bytes fetched), the busiest APIs, and the
recent events for the whole deployment or one API. Activity needs the database; it keeps
`audit_retention_days` (30) of events, while the audit stream keeps everything. The console shows credential
*references* only, never values. From there a signed-in approver can **revoke a grant**,
**block** or **unblock** an API, **forget** a learned one (its open review is closed as the App, and whatever waited on a
person for it is cancelled), **invalidate a credential** (the next call asks for a fresh key), **re-learn** a provisional API from a documentation or spec URL you
give it, **choose its authentication** when the spec and the model couldn't tell, **change any
operation's decision** (read, write approved each time, write approved automatically with a daily
cap, or exclude; enabling writes asks for a write credential when the entry has none),
**allow slashes** in path arguments that are file paths or refs, **re-apply** enabled writes a
proposal lost, **re-check** its source, and **clear a suspension**. A decision on a verified
entry lands in the entry's proposal and is merged as the App at once; the live entry follows
when the registry syncs. Large APIs are shown by group (the spec's tags), with the
operations that need attention first and a search box. Every action is recorded in the
audit log with who did it. The console follows the approval pages' sign-in
(`approvers.mode: trusted_header` behind your auth proxy); `approvers.console: open` serves it
without sign-in on a laptop, `off` disables it.

## Writes

Reads are the default; writes are opt-in at every level:

1. An approver enables the operation on a **verified** entry (it lands in the entry's review
   as `writable: true`) and the entry has a separate write credential (`write_auth`). The
   approver adding a read key can also tick *"allow approved writes with this key"*.
2. The agent is allowed to request writes on that API (`write_apis` in its caller config).
3. Each write is **approved by a human** for that exact request, or matches a **grant**.

**Writes nobody has enabled yet.** When an agent asks to write through an operation that is not
enabled, or on an API with no write credential, the refusal is not silent. librarian opens one
*enable-write request* per operation (shared by every agent and replica that asks), tells the
approvers (a Slack card, and the console's waiting list) and answers the agent with
`write_not_enabled` (MCP) or a rejection saying approvers were asked (A2A). An approver follows
the link and **enables** or **declines**. Enable carries the change into the entry's review
proposal, for the usual approve or deny; if the API has no write credential, the approver chooses
a dedicated `<api>/write` credential (asked for through the secure key page, with the least
permission those writes need, inferred by the model when one is configured) or reuses the read
key. Decline keeps the operation refused and quiet for a week. The console's API page also offers
*Enable writes* on any write operation that is not enabled, without waiting for an agent to ask.
A decision an approver makes in the console or on an enable page is the approval: on a verified
entry it lands in the entry's proposal and is merged as the App right away (turn
`publish.merge_console_decisions` off to merge on GitHub instead); the live entry follows once the
registry syncs.
A flow that needs several operations (create a blob, a tree, a commit, a ref) names the rest in
`also_needs`, so one request and one approver action cover them all; `enable_status` tells the
agent where the request stands (pending, enabled and waiting for the merge, live, declined until a
date). Nobody edits YAML.

**Automatic approval.** When enabling writes (on the enable page, or with the per-operation
*Change* control on the console's API page) an approver can choose *approve writes automatically*:
from then on a write to that operation runs right after the policy checks, with no card, up to a
daily cap (default 100; then a person is asked again). It is recorded on the operation
(`approval: auto`, `auto_limit_per_day`), goes through the same proposal and merge, is audited as
`write_auto` (the console's Activity page lists them) and logged; `writes.announce_auto: true`
also posts each one to the channel as an FYI with a *Require approval again* link (off by
default: it gets noisy). The same link is on the console's API page, where the *Change* control
puts per-write approval back. DELETE operations are never approved
automatically, the write rate limit still applies, and a suspended operation is refused as
always. The *Change* control also flips any decided operation: read, write, automatic or exclude.

**Path arguments with slashes.** A path argument may not contain `/` unless the parameter is marked
`allow_slash: true` (inferred when the API is learned for parameters that are file paths or git
refs, such as GitHub's `path`, `ref` and `branch`; the proposal shows it). Even then, empty, `.`
and `..` segments, a leading or trailing slash and encoded slashes are refused. The console's API
page offers *Allow slashes* for parameters that look like paths, which lands in the entry's review.

The approval card (Slack) shows the operation, arguments and full body. On the approval page the
approver can **approve once**, **approve and allow similar writes** (a grant for that agent and
operation, with the argument values they choose kept fixed, a length of their choosing from a day
to until revoked, and an optional use cap), or **deny**. DELETE operations can only ever be
approved once. The task waits in `AUTH_REQUIRED` and completes on its own after the decision.
Grants are adjusted or revoked from the console's Grants page at any time; `writes.max_grant_days`
caps how long one may last (no cap by default).

Who may approve (`writes.approvers`): the **requester** (the person the agent acts for, or the
agent's `owner`), the **approver group**, or either (default). Approvers sign in through your
SSO proxy (`approvers.mode: trusted_header`); the approver group is `approvers.group`, as your
identity provider names it (the chart's `oauth2Proxy.approverGroup`). The Slack user group
(`notifier.slack.approver_group`) is only who gets pinged. Agents can never approve.

**Autonomous agents** (no person behind them) get an `owner`, who stands in as requester. For
unattended work, configure bounded grants:

```yaml
callers:
  - agent: triage-bot
    key_ref: librarian/callers/triage-bot
    owner: ops@example.com
    write_apis: ["jira"]
writes:
  approvers: requester_or_group
  grants:
    - agent: triage-bot
      api: jira
      operation: addLabel
      pinned_args: {}                  # e.g. {issueIdOrKey: PAY-1}; {} allows any issue
      max_uses_per_day: 50
      notify_owner: true               # FYI to the channel on each use
```

Each use of a grant is posted to the channel. `writes.announce_grants: false` stops that for all
grants, and the console's Grants page turns notices off per grant; every use is still in the
audit log and on the Activity page.

An agent with no owner and no person can only be approved by the approver group; if no one could
approve, the write is refused immediately. `librarian grants list|revoke` and `librarian approvals
list` manage it all; revoking is the kill switch.

## The registry

One YAML file per API, normally in a git repo you review by pull request. Onboarded entries start
as **provisional**:

- GET/HEAD operations are reads and usable immediately.
- POST operations that look like searches are marked `unknown` and blocked until a human sets
  `access: read` + `read_basis: reviewed` **and** the entry is `verified`. Nobody edits that by
  hand: the console lists these operations under *Needs attention* with a read / write /
  exclude choice, and the decision is carried into the entry's open review pull request.
- Everything else is a write: refused unless a reviewer enables it (see [Writes](#writes)).

**Review loop.** With `publish.type: github`, every learned API is also opened as a pull request
against your registry repo, with a review checklist and the operations that need a decision. Run a
[git-sync](https://github.com/kubernetes/git-sync) sidecar (or any process that updates the
registry directory) and merged changes such as `status: verified` are picked up automatically:
the registry hot-reloads when files change.

```yaml
publish:
  type: github
  repo: acme/api-registry
  directory: registry
  github_app:                      # recommended: owned by the org, short-lived tokens
    app_id: "123456"
    private_key_file: .librarian/github-app.pem
  # or token: via LIBRARIAN_PUBLISH__TOKEN (contents:write + pull_requests:write)
```

`librarian github-app create --repo acme/api-registry` creates that GitHub App in one step:
one click on GitHub, install it on the repo, and it saves the key and prints the settings.
The same App lets git-sync read a private registry repo (see the Helm chart README).

When learning an API, the librarian also looks for a cheap "who am I" read (`/me`, `/myself`,
`/user`, ...) and uses it to test every new credential.

Validate in CI with `uv run librarian registry validate`. Format reference:
[docs/registry.md](docs/registry.md). Example: [`examples/registry/jira.yaml`](examples/registry/jira.yaml).

## Security

Read [docs/threat-model.md](docs/threat-model.md). Highlights: deterministic policy engine;
host-locked URL building (callers can't choose hosts); same-origin-only redirects; response size
caps; SSRF protection for onboarding and provisional entries; one-time, expiring approval links;
secrets never logged or returned. Report vulnerabilities via [SECURITY.md](SECURITY.md).

## Development

```bash
uv sync
uv run ruff check . && uv run ruff format --check .
uv run pyright          # strict mode
uv run pytest
```

## Roadmap

### Done

- **v0.2**: durable state (SQLite/Postgres) and stable approval links; per-agent API
  allowlists; 401 vs 403 handling; DNS-rebinding-proof SSRF guard; GitHub review PRs + hot reload;
  AWS Secrets Manager; field selection and pagination styles; OpenTelemetry
- **v0.3**: writes behind human approval; grants (approve-and-allow-similar, config grants for
  autonomous agents); agent owners; planner write proposals; grants/approvals CLI
- **v0.4**: Helm chart (oauth2-proxy sign-in, git-sync registry, hardened pod), separate pages
  listener, registry overlay, release workflow, example client, LiteLLM compose
- **v0.5**: verified principals (signed tokens from your identity provider; optional and
  required modes)
- **v0.6**: big APIs: ranked operation search, discovery-built glossary (e.g. Jira custom
  fields), recipes replayed without the LLM
- **v0.7**: MCP endpoint; LiteLLM agent gateway (MCP + A2A); exactly-once writes across
  replicas; integration suite
- **v0.8**: APIs without a spec (documentation, model knowledge) with quote-checked extraction
  and confirm-on-use; change detection (spec/docs re-checks, deprecation, removal and
  response-shape signals) with suspensions and review proposals
- **v0.9**: finding documentation from just a base URL (usual places, then web search via the
  model or SearXNG/Brave), trust-ranked sources; Anthropic API format for gateways
- **v0.10**: production replicas with no volumes (learned APIs in Postgres, disruption budget,
  spread across nodes, survives a database failover); GitHub App for the registry repo, created
  with one command; step-by-step docs; on PyPI as `librarian-a2a`

- **v0.11**: internal APIs, scoped: agents learn internal APIs on demand within
  the domains you list (`internal_hosts`), instead of all-or-nothing `allow_private_networks`

- **v0.11.1**: the Helm chart published to GHCR as an OCI artifact with every release
- **v0.11.2**: fixes from the first real deployment (chart ids, `sslmode`, `github-app
  init-repo`), `learning.model`, the Entra ID recipe and other deployment docs
- **v0.12**: the console: what librarian knows and what waits on people, with revoke, block,
  forget, invalidate, re-learn from a documentation URL, re-check and clear, all audited
- **v0.12.1**: authentication settled by librarian when a spec declares none (inferred from the
  model, or chosen in the console); a notice for every learned API; model refusals explained;
  `fallback_model`
- **v0.12.2**: the console copes with large APIs (groups, search, needs-attention first);
  forgetting an API closes its review and cleans up
- **v0.13**: the review step: a Slack card with a Review button to a signed-in page where an
  approver decides operations that need it and approves (merged as the App; merging makes the
  API verified) or denies; credential guidance inferred with the authentication; the pages
  serve their own tab icon
- **v0.14**: acting as the person: per-person OAuth sign-in (each person's own token, the API
  enforces their access; verified live against GitHub); the console redesigned as an admin
  tool with navigation, a people page and sign-in management; screenshots; the site at
  librarian-agent.com
- **v0.15**: rate limits shared across replicas (counted in the database); audit events kept
  in the database too, with an Activity page in the console: the last seven days in numbers
  (reads, writes, denials, requests answered without a model), the busiest APIs, recent
  events per API
- **v0.16**: writes nobody has enabled yet become requests: the agent is told approvers were
  asked, approvers get a Slack card and a console to-do with Enable or Decline, Enable lands in
  the entry's review and asks for a dedicated write credential when there is none, declines
  stay quiet for a week, and the console enables any write operation on demand
- **v0.17**: from the first real write flow: slashes in path arguments that are file paths or
  refs (`allow_slash`, inferred when learning, allowed from the console), one enable request
  for a whole flow (`also_needs`) with ticks on the page, `enable_status` for agents, a
  wall-clock budget for plain-language requests (`fetch_timeout_seconds`, partial results
  instead of outliving the caller) and a hard cap on the planner's calls
- **v0.17.1**: startup with a large registry: registry files are parsed with libyaml (5x
  faster; a 1 MB GitHub entry took seconds on a throttled core and the liveness probe killed
  the pod), the chart adds a startup probe and `probes` overrides, and `allow_slash` is
  omitted when false so entries written by 0.17 still load on 0.16 replicas
- **v0.17.2**: a decision on a verified entry is applied on top of what its open proposal
  already holds, so a second decision no longer overwrites the first (0.17.0 and 0.17.1
  rebuilt the file from the live entry); a decision made in the console or on an enable
  page is merged as the App at once (`publish.merge_console_decisions`, default on); the
  console's API page offers *Re-apply and merge* for enabled writes a proposal lost
- **v0.18**: automatic approval per operation, chosen when writes are enabled or later in the
  console: writes run without a person up to a daily cap, each one audited and, if you turn it
  on, posted as an FYI with a one-click *Require approval again*; never for DELETE. Every
  operation on the console's API page gets a *Change* control (read, write, automatic, exclude)
  instead of only undecided ones
- **v0.18.1**: the per-write FYI is off by default (`writes.announce_auto`); automatic writes
  are always in the audit log and the server log
- **v0.18.2**: fix: the write approval page checked the approver's groups against the Slack
  user group instead of `approvers.group`, so with no Slack group nobody but the requester could
  approve and the refusal said "members of None". The refusal now names the requester and the
  configured group
- **v0.19**: approvers choose how long an *allow similar writes* grant lasts: 1, 7, 30 or 90
  days, a number of their own, or until revoked (the page used to offer 1, 7 or 30 and the
  backend capped at 90). The console's Grants page sets a new expiry on any grant. Optional
  ceiling `writes.max_grant_days`
- **v0.19.1**: idempotency keys work across replicas and restarts, and for grant and automatic
  writes too (they were matched in one process's memory only, so a retry on the other pod sent
  the write again and opened a second approval). A repeat while the first is pending joins
  it. Every executed write answers with a `message` saying how it ran (approved by whom,
  under which grant, automatically, or not sent again); `write_status` dates the result and
  says it is as of execution. A decision is stored before waiters see it (a waiter could try
  to claim a write the database still showed as pending). GitHub's SAML enforcement 403 is
  explained, on the credential page and in the error. Upstream rate limiting (429, or a 403
  with the limit exhausted) is told apart from a permission problem: the error and the audit
  record say when the limit resets, and the API's own error message is kept (redacted) in the
  audit record. `writes.announce_grants: false` (chart: `config.writes.announce_grants`) stops
  the channel post on each use of a standing grant, and the Grants page turns notices off per
  grant

### Next

- Console actions answer at once; the proposal, the model's credential guidance and the Slack
  updates finish in the background, with the outcome shown on the API page
- Verify the SearXNG and Brave documentation-search providers and the Google and Microsoft
  sign-in providers live

### Later

- More secret stores: Vault, GCP Secret Manager, Azure Key Vault
- kagent: a tested recipe for using librarian as a `RemoteMCPServer`
- A community registry of reviewed, signed entries

## License

[Apache-2.0](LICENSE)
