* fix(cli): inherit path-item-level OpenAPI parameters
OpenAPI 3.x lets a Path Item Object declare `parameters` shared by every
operation under that path, with operation-level entries overriding them by
(name, in). The codegen read only `op.get("parameters")`, so inherited
parameters were dropped silently — the generated command kept the URL
template but had nothing registered to fill it.
Real-world impact: the Xero Accounting API declares its required
`xero-tenant-id` header once per path item across all 138 paths. Before this
change `build_command_spec` produced 78 commands and none carried the
header, so every generated command 403s. Nothing warned.
Adds `operation_parameters()`, which merges the two levels and dereferences
both, and routes the pre-computed spec path (`build_command_spec`) and the
interactive path (`build_command_index` -> `build_parser_from_operation`)
through it. Unresolvable refs, non-dict entries, and nameless parameters are
skipped as before. Specs without path-item parameters are unaffected.
* fix(cli): narrow path-item inheritance to path and query parameters
Per review: headers do not translate into a Python interface function, and
outbound headers are transport configuration
(SystemSettings.PythonSettings.http.headers, the CLI's -H) or a security-scheme
concern — not a per-command argument.
Path-item `in: header` and `in: cookie` entries are now dropped rather than
inherited. Operation-level parameters are untouched, so an operation that
declares its own header keeps today's behavior.
Scope is now only what cannot be supplied any other way: `in: path` and
`in: query` parameters declared on the Path Item Object.
* fix(cli): merge allOf compositions in response schemas
Response-schema $refs were already expanded by deref_schema, but a schema
whose top level is an allOf composition was left unflattened. The properties
live inside the members, so a consumer reading them off the composition finds
none. fetcher_gen._data_schema looks for `properties.results`, gets nothing,
and falls through to _unwrap_schema_envelopes, which handles oneOf and anyOf
but not allOf. The generated Data model ends up with no fields.
Codat's listBills response is `allOf: [{properties: {results}}, {properties:
{_links, pageNumber, pageSize, totalResults}}]`, which produced an empty model.
merge_allof collapses a composition of object subschemas into one object
schema: properties unioned with the first declaration winning on conflict,
required unioned, and sibling keywords on the allOf node outranking members.
A composition that is not a plain intersection of object subschemas (a member
carrying its own oneOf/anyOf, or a non-object type) is returned unchanged
rather than flattened into something the spec does not describe. Nested
compositions are merged too, so a $ref inside a merged member still resolves.
Applied to extract_response_schema and extract_response_schemas.
Measured across five specs that use the pattern, counting operations whose
response schema exposes readable properties: 244 of 588 before, 394 after
(Codat Accounting 36 to 88, Codat Banking 0 to 8, Codat Commerce 1 to 11,
Rebilly 62 to 104, Box 145 to 183).
* fix(cli): merge allOf compositions in request body schemas
Same gap as the response side. request_body_parameters needs `type: object`
or `properties` to flatten a body into parameters, and an allOf composition
carries neither, so a POST or PUT generated from one gets no body parameters
at all.
extract_request_body_schema now runs merge_allof after deref_schema, matching
extract_response_schema.
Across the same five specs, counting request bodies that produce parameters:
138 of 206 before, 177 after (Codat Accounting 1 to 26 of its 30 bodies,
Rebilly 50 to 63, Box 87 to 88).
* fix(cli): stop annotation keywords blocking an allOf merge
_mergeable_allof_members required every member's keys to come from a small
whitelist, so any unrecognized keyword disqualified the whole composition.
Codat's PagingInfo carries `definitions` next to its properties, which meant
the live Codat-Accounting.yaml never merged and its response models stayed
empty. The APIs.guru copy of the same API omits that key, which is why the
earlier measurements looked clean.
The check is now a blocklist. A member blocks the merge when it carries its
own combinator (oneOf/anyOf/allOf/not), an unresolved $ref left by a cycle,
a scalar or array constraint (enum/const/items), or a type other than object.
Annotation keywords that do not affect the intersection (title, description,
examples, definitions, additionalProperties, vendor x- keys) no longer block
it, and are dropped from the merged result along with the members.
On the live Codat spec, operations whose response schema exposes readable
properties go from 36 of 127 to 114. Across the five specs measured earlier:
244 of 588 before, 488 after (was 394 under the whitelist).
Corpus check over 2,816 response and request-body extractions in 6 specs:
567 changed, every one attributable to an allOf in the pre-merge output,
0 unexplained. Twilio declares no allOf compositions and is unchanged.
* fix(cli): emit a name once when it is both a parameter and a body property
_signature_params appended request-body properties and then operation
parameters without deduplicating, so a name declared in both places produced
two function parameters with the same name and the generated module raised
SyntaxError.
Codat's push endpoints hit this: /push/bankAccounts/{accountId}/bankTransactions
declares accountId as a path parameter and repeats it in the request body, so
connections.push.bankAccounts.bankTransactions generated `accountId: str` and
`accountId: str = None` in one signature.
The operation parameter wins, since it carries the path placeholder and its own
required flag. The request payload is unchanged: _render_body_block derives body
fields from request_body_schema rather than from this list, so accountId is
still sent in the body.
This predates the path-item and allOf work in this PR. On origin/v5 the same
spec produces the same duplicate; on this branch the Codat spec generates 91
commands with 0 duplicated signatures, down from 1.
* fix(cli): use the safe identifier for keyword-named body properties
_render_body_block already writes `{'from': from_}`, keying the payload by the
wire name and reading a safe identifier, but _signature_params declared the raw
name. A body property named after a Python keyword therefore emitted
`from: str = None` and the generated module raised SyntaxError.
This did not fire before the allOf work in this PR, because a composed request
body produced no body parameters at all. Codat's Transfer body is such a
composition and has a `from` property, so connections.push.transfers started
generating invalid syntax once the body flattened.
_signature_params now emits safe_field_name(name)[0], the same helper
fetcher_gen and pydantic_gen already use. The body/query split in
_render_body_block compares against the safe names too: it previously matched
signature names against wire names, so `from_` was not recognized as a body
field and leaked into the query string as a `from_` parameter.
End to end on Codat-Accounting.yaml, generating the full extension and byte
compiling every emitted module: 136 files, 0 failures. origin/v5 emits 1 file
that does not compile (duplicate argument 'accountId').
Known gap left alone: an operation parameter named after a keyword would still
break, since the query and path lines use the raw name as a local variable.
None of the five specs checked declares one.
* fix(cli): do not treat instance data as schema, and sanitize POST identifiers
Two defects found by generating extensions from real specs and byte compiling
every emitted module.
merge_allof and deref_schema walked every value in a schema dict, including
keywords that hold instance data rather than subschemas. A `default` of
`{"allOf": [{"a": 1}, {"b": 2}]}` was rewritten to `{"type": "object"}`, and a
`$ref` key inside an example was resolved as if it were a reference. Both now
copy `default`, `example`, `examples`, `enum`, `const` and vendor `x-` values
through untouched.
_signature_params emitted operation parameter names verbatim, so a wire name
that is not a Python identifier produced invalid syntax. Rebilly declares an
`Organization-Id` header and a `REB-APIKEY` security header, which emitted
`REB-APIKEY: str = None`. It now uses safe_field_name, matching fetcher_gen,
and _render_body_block keys the query dict by the wire name while reading the
safe identifier so the request is unchanged.
This surfaced through the allOf work rather than being caused by it: a composed
request body used to yield no properties, so `has_body` was false in
package_gen and the command was generated as a GET fetcher, where identifiers
were already sanitized. With bodies flattening, 14 of Codat's 24 POST endpoints
correctly move to the POST path, which had never been exercised by these specs.
Generating the full extension and compiling every module:
Codat-Accounting.yaml v5: 1 failure branch: 0 (136 files)
Rebilly 2.1 v5: 14 failures branch: 1 (97 files)
The one remaining Rebilly failure is `async def 3dsecure(` in the router, which
fails identically on v5. Sanitizing that is a naming decision about the command
surface, so it is left alone here.
Known gap: a non-credential header parameter is still placed in the query
string on the POST path. Pre-existing, and unchanged by this PR.
* fix(cli): resolve signature collisions on the emitted identifier
The previous dedupe compared wire names, but the failure it prevents depends on
the sanitized identifier. Two wire names can collapse onto one (`from` and
`from_`, `Organization-Id` and `Organization_Id`), and operation_parameters
keeps a name declared twice under different `in` locations by design, so both
cases could still emit a duplicate argument.
_signature_params now tracks the identifiers it has emitted and skips anything
that would repeat one. Emission order is unchanged, so existing signatures are
byte-identical; only genuinely colliding names are dropped, and operation
parameters still win over body properties.
Neither collision occurs in Codat-Accounting.yaml or Rebilly 2.1. This closes
the class rather than the instance, since the instances are what this PR has
been finding.
Generation unchanged: Codat 136 files 0 failures, Rebilly 97 files 1 failure
(`async def 3dsecure(`, identical on v5).
* test(cli): cover the instance-data guard, and drop a redundant branch
Mutation checking the new tests (revert each fix, confirm its test fails)
found two problems with the previous commits.
The guard that stops merge_allof and deref_schema from walking into `default`,
`example`, `enum`, `const` and vendor values shipped with no test at all. It is
now covered from both directions: a default holding an `allOf` key keeps its
value, and a `$ref` inside a default stays a literal while a real reference
beside it still resolves.
The body-property loop also carried a redundant check. It tested the raw wire
name against the operation parameter names before testing the sanitized one,
but `operation_param_names` holds both forms and `safe_field_name` is idempotent
on identifiers, so the first test could never fire alone. Reverting it left
every test passing, which is what surfaced it. Removed, and the sanitized name
is now computed once.
All nine mutations are caught by the suite: both data guards, the allOf
blocklist in each direction, the response merge wiring, body/parameter
precedence, the keyword identifier, the body/query split and the
emitted-identifier dedupe.
cli/tests at 1,954 passed against the same 80 environment failures as the base.
Generation unchanged: Codat 136 files 0 failures, Rebilly 97 files 1 failure.
* fix(cli): route required path-item headers/cookies through credential handling
* fix(cli): narrow Popen.stdin/stdout for ty in run_in_obb fixture
---------
Co-authored-by: deeleeramone <>
ODP Command-Line Interface
Overview
openbb-cli is a command-line interface for the OpenBB Platform and any other OpenAPI 3.x server. It runs in three modes:
- Non-TTY one-shot (default) —
openbb <command.path> [--key value]dispatches one command, prints a JSON line, exits. The form CI tools and agents reach for. - Batch —
openbb --batchreads NDJSON requests from stdin, fans them out concurrently, writes NDJSON responses to stdout. - Interactive REPL —
openbb -idrops into a rich-output prompt with auto-completion, menu navigation, and routine playback.
Backends are pluggable: dispatch through an in-process obb namespace, an openbb-platform-api HTTP server, or any OpenAPI 3.x server (e.g. https://api.congress.gov) without touching code.
The full user docs live at docs.openbb.co/odp/cli.
Backends
Four sources for the command surface. Three are OpenBB-aware; the fourth is the generic OpenAPI 3.x fallback.
| Source | Flag | Cold start | When to use |
|---|---|---|---|
In-process obb |
(default) | slow (import openbb ~2s) |
Local script with openbb already installed; no separate process to manage. |
| OpenBB Platform server | --server URL |
fast after first fetch | Long-running shared server (openbb-platform-api) — many CLI invocations or many users amortize the import cost. |
OpenBB .spec file |
--spec PATH |
fastest (~50ms) | Ship the spec next to scripts/agents; skips OpenAPI fetch + parse on every call. Generate with openbb --generate-spec --server URL -o file.spec. |
| Arbitrary OpenAPI 3.x server | --server URL (non-OpenBB host) |
depends on host | Any external API with an OpenAPI document — Congress.gov, NY Fed, USDA, NWS, your own FastAPI service. |
The first three speak the same OpenBB Platform surface (the OBBject envelope, providers, the obb.reference menu tree). The fourth treats whatever the upstream publishes as the truth — same dispatcher, same parser, same auth, just no OpenBB-specific affordances.
What's different about OpenBB upstreams
Multi-provider Pydantic models and the OBBject envelope are OpenBB Platform conventions, not OpenAPI ones. The CLI detects an OpenBB endpoint by the presence of a provider discriminator parameter on the operation and turns on extra behavior:
| Feature | OpenBB upstream | Generic OpenAPI upstream |
|---|---|---|
| Response envelope | OBBject — id, results, provider, warnings, chart, extra |
Whatever the server returns |
--describe COMMAND |
Groups parameters + result schema per provider | Flat {parameters, output_schema} |
--describe COMMAND:PROVIDER |
Returns just that provider's slice | Suffix ignored |
| Per-provider argparse narrowing | --provider X filters accepted flags; passing a flag from another provider errors at parse time |
Every declared flag accepted |
| Help text per provider | (provider: …) annotations stripped; only sections relevant to the chosen provider |
Description shown verbatim |
| REPL menu tree | Mirrors obb.reference (router descriptions, command groupings) |
Built from URL path prefixes |
| Boolean flags | --flag / --no-flag toggle (OpenBB exposes default: true booleans) |
Same — applies to any spec |
Everything else — --list-commands, --describe COMMAND, --batch, headers/query auth, spec generation, REPL playback — works identically against both kinds of upstream.
Installation
pip install openbb-cli
Optional extras:
pip install "openbb-cli[charting]" # Plotly + openbb-charting
pip install "openbb-cli[interactive]" # PyWry browser-based tables
pip install "openbb-cli[all]" # everything
Quick start
One-shot dispatch
# Against the local in-process obb namespace (the historical default)
openbb economy.gdp --provider oecd --limit 5
# Against any OpenAPI server — auto-detects spec layout, $ref-resolves params,
# auto-extracts HTML-embedded specs (e.g. Congress.gov)
openbb --server http://127.0.0.1:6900 equity.price.historical --symbol AAPL --provider fmp
openbb --server https://api.congress.gov bill --limit 5
Spec files for instant cold-start
# Generate once; ship alongside the script that invokes the CLI
openbb --generate-spec --server https://api.congress.gov --output congress.spec
# Subsequent calls skip the OpenAPI fetch + parse on every invocation
openbb --spec congress.spec law --congress 119 --limit 5
Generate an installable OpenBB extension from a spec
A .spec file is enough to dispatch commands directly. Going one step further, --generate-extension turns that spec into a full installable OpenBB Platform extension package — Provider(...) + Fetcher classes + a router that mirrors the upstream's namespace tree — that registers with openbb-build like any first-party extension. After install, every command shows up on the typed obb.* surface (auto-completion, obb.reference, OBBject envelopes, the works).
End-to-end with the NY Fed Markets API:
# 1. Snapshot the OpenAPI surface as a spec file. NY Fed publishes its
# spec at a non-default path, so point --openapi-path at the YAML.
openbb --generate-spec \
--server https://markets.newyorkfed.org \
--openapi-path /static/docs/markets-api.yml \
--output nyfed.spec
# wrote 55 commands to nyfed.spec
# 2. Generate a complete extension project from that spec
openbb --generate-extension \
--spec nyfed.spec \
--provider-name nyfed \
--output ./openbb-nyfed
# wrote project to ./openbb-nyfed/openbb-nyfed
# providers (1): nyfed
# routers (10): ambs, fxs, guidesheets, marketshare, pd, rates, rp, seclending, soma, tsy
# fetchers: 55 GET (across all providers)
# POST: 0 local-compute commands
# install: pip install -e ./openbb-nyfed/openbb-nyfed
# build: openbb-build
# 3. Install + register with the OpenBB Platform
pip install -e ./openbb-nyfed/openbb-nyfed
openbb-build
# 4. The new namespaces are live on the typed obb surface
python -c "from openbb import obb; print(obb.nyfed.rates.all.latest().to_df())"
(--openapi-path is only needed when the upstream doesn't expose /openapi.json directly. For servers that do — most FastAPI deployments, Congress.gov via the embedded-spec scraper — drop the flag.)
What --generate-extension produces:
openbb-nyfed/
├── pyproject.toml # PEP 621 / Hatchling — every provider + router declared as entry points
├── README.md
└── openbb_nyfed/
├── providers/
│ └── nyfed/
│ ├── __init__.py # nyfed_provider = Provider(name="nyfed", fetcher_dict={...}, credentials=[...])
│ └── models/<command>.py # one Fetcher class per command (QueryParams + Data + Fetcher)
└── routers/
├── rates.py # @router.command(model="RatesAllLatest") — typed signature
├── ambs.py
└── ... # one router per top-level namespace; sub-routers nest via include_router(prefix="/sub")
Every flag is optional except --spec:
| Flag | Default | What it controls |
|---|---|---|
--output PATH |
required | Project root directory written to disk. |
--provider-name NAME |
derived from --output basename |
Snake-case provider identifier — drives credential lookup keys (credentials.get(f"{provider}_api_key")). |
--project-name NAME |
openbb-<provider-name> |
PyPI distribution name in pyproject.toml. Use dashes. |
--package-name NAME |
openbb_<provider-name> |
Snake-case Python package name. |
--router-name NAME |
<provider-name> |
Top-level router identifier. |
Bring-your-own-key APIs: parameter names that look like credentials (api_key, apikey, app_token, X-API-Key, Authorization, client_secret, bearer_token, …) are auto-promoted to the provider's credentials=[...] list. After install, openbb reads them through the standard user-settings flow (OBB_USER_SETTINGS / ~/.openbb_platform/user_settings.json) — no per-call flag needed.
Multi-spec — combine APIs under one CLI
Repeat --spec NAME=PATH to mount more than one spec at once. Each spec's commands get prefixed with its namespace, and each backend keeps its own base_url, headers, and query params:
openbb \
--spec congress=congress.spec \
--spec nyfed=nyfed.spec \
congress.bill --limit 5
openbb \
--spec congress=congress.spec \
--spec nyfed=nyfed.spec \
nyfed.markets.ambs --operation rates
Scope auth per namespace by prefixing the flag value with <NAME>::
openbb \
--spec congress=congress.spec \
--spec usda=usda.spec \
-Q congress:api_key="$CONGRESS_KEY" \
-Q usda:api_key="$USDA_KEY" \
-H usda:Authorization="Bearer $USDA_BEARER" \
congress.bill --limit 5
Tokens without a namespace prefix (-H Authorization=..., -Q api_key=...) apply to every backend; namespace-scoped tokens override on conflict for that one backend only.
The same shape works in TOML, which is the better fit when scripts reach for the same set repeatedly:
[specs.congress]
path = "/path/congress.spec"
[specs.congress.query]
api_key = "..."
[specs.usda]
path = "/path/usda.spec"
[specs.usda.headers]
Authorization = "Bearer ..."
[specs.usda.query]
api_key = "..."
--list-commands aggregates across every namespace; --describe NAMESPACE.command[:provider] resolves to the right backend automatically.
A single unnamed --spec PATH keeps the flat (unprefixed) command surface — backward-compatible with the historical single-spec form.
Auth
Pick whichever the upstream API uses:
# Headers (Authorization, X-API-Key, ...)
openbb --server URL -H "Authorization: Bearer xxx" -H "X-Tenant: acme" cmd
# Query-string params (e.g. ?api_key=... like Congress.gov, USDA, NWS)
openbb --server URL -Q api_key=xxx cmd
# or via env — OPENBB_HTTP_QUERY_<NAME> auto-injected
OPENBB_HTTP_QUERY_API_KEY=xxx openbb --server URL cmd
Auth hooks (RBAC, token refresh, dynamic credentials)
Static headers and query params cover the common case. For RBAC, expiring tokens, or per-user credentials sourced from a vault, register an importable callable as an auth hook. Configured in TOML by module:attribute path — global, or per [specs.<ns>]:
auth-hook = "myapp.auth:default_hook" # applies to every backend
[specs.congress]
path = "/path/congress.spec"
auth-hook = "myapp.auth:congress_hook" # overrides the global for this one
[specs.internal]
path = "/path/internal.spec"
auth-hook = "myapp.auth:rbac_hook"
The hook receives an AuthContext (namespace, command, params, method) and returns an AuthDecision:
# myapp/auth.py
from openbb_cli.auth import AuthContext, AuthDecision
from myapp.identity import current_user, get_token
def rbac_hook(ctx: AuthContext) -> AuthDecision:
user = current_user()
if not user.can_access(ctx.namespace, ctx.command):
return AuthDecision(
allow=False, deny_reason=f"{user.role} cannot call {ctx.command}"
)
return AuthDecision(headers={"Authorization": f"Bearer {get_token(user)}"})
Hooks may be sync or async; coroutines are awaited. Returned headers and query params merge on top of the dispatcher's static auth (hook wins on conflict). allow=False short-circuits the dispatch with an AccessDenied error response — no network call is made.
Introspection is gated by the same hook: --list-commands invokes it for every command and silently drops denied entries from the listing, and --describe COMMAND returns AccessDenied when the hook denies. RBAC implementations that hide endpoints therefore hide them everywhere — discovery, schema, and dispatch all see the same surface.
Batch
NDJSON request/response over stdin/stdout, concurrent dispatch:
cat <<'EOF' | openbb --spec congress.spec --batch
{"id":"a","command":"bill","params":{"limit":5,"format":"json"}}
{"id":"b","command":"law","params":{"congress":119,"format":"json"}}
{"id":"c","command":"__commands__"}
EOF
Introspection
openbb --spec congress.spec --list-commands # every command + short description
openbb --spec congress.spec --describe bill # full schema (params + response)
The same calls work as reserved commands in batch mode (__commands__, __schema__).
Interactive REPL
openbb -i # in-process obb backend
openbb -i --spec congress.spec # spec-driven; no obb install required
openbb -i --server URL # live OpenAPI fetch
The REPL adds menu navigation (bill, .., home, exit), tab/auto-completion against parser flags, --help per command, the OBBject registry, and routine playback (exe --file ...).
Configuration
Layered, all optional. Resolution order (lowest → highest priority):
- Built-in defaults
[tool.openbb-cli]in nearest ancestorpyproject.toml~/.openbb_platform/openbb.toml(user-global; same dir asuser_settings.json)./openbb.toml(project-local, walks up from CWD)--config PATH(orOPENBB_CLI_CONFIG)~/.openbb_platform/.envand--env-file PATHOPENBB_*shell exports- CLI flags
Bootstrap a documented template:
openbb --print-config-template > ~/.openbb_platform/openbb.toml
Inspect what's currently active across all layers:
openbb --show-config
Schema:
# Backend / dispatch — pick one of these forms
server = "https://api.congress.gov" # live OpenAPI fetch
spec = "/path/to/api.spec" # single spec (flat surface)
batch-concurrency = 16
# Multi-spec: each table mounts under its own namespace
[specs.congress]
path = "/path/congress.spec"
[specs.congress.query]
api_key = "..."
[specs.usda]
path = "/path/usda.spec"
[specs.usda.headers]
Authorization = "Bearer ..."
[specs.usda.query]
api_key = "..."
# REPL display preferences (top-level shortcuts)
output-mode = "rich" # rich | json | tsv | html
flair = ":fox_face"
timezone = "America/New_York"
rich-style = "dark"
[headers] # global — applied to every backend
Authorization = "Bearer ..."
[query] # global — applied to every backend
api_key = "..."
[settings]
# Every other Settings field — same surface as the /settings/ REPL menu
allowed-number-of-rows = 50
use-prompt-toolkit = true
toolbar-hint = false
Documentation
Full user documentation: docs.openbb.co/odp/cli
API reference: docs.openbb.co/platform