The connector contract¶
A connector is a small synchronous Python function plus metadata. The
function does the work — call an HTTP API, parse the body, return raw data — and
a decorator from parsimony attaches the agent-facing contract: a description,
an output schema, tags, declared secrets (secrets=, redacted from provenance),
and declared required env vars (requires=, the env vars a call needs to succeed).
The framework wraps every return value into a Result
with provenance attached.
Connectors are plain def functions. The three decorators reject async def
with a TypeError at import time: there is no async/await anywhere in a
connector.
Anatomy¶
from typing import Annotated
import pandas as pd
from parsimony import Namespace
from parsimony.connector import connector
from parsimony.errors import EmptyDataError, InvalidParameterError, ParseError
from parsimony.result import Column, ColumnRole, OutputSpec
FETCH_OUTPUT = OutputSpec(
columns=[
Column(name="series_id", role=ColumnRole.KEY, namespace="fred"),
Column(name="title", role=ColumnRole.TITLE),
Column(name="units_short", role=ColumnRole.METADATA),
Column(name="date", role=ColumnRole.DATA),
Column(name="value", role=ColumnRole.DATA),
]
)
@connector(output=FETCH_OUTPUT, tags=["macro"], secrets=("api_key",), requires=("FRED_API_KEY",))
def fred_fetch(
series_id: Annotated[str, Namespace("fred")],
observation_start: str | None = None,
api_key: str = "",
) -> pd.DataFrame:
"""Fetch FRED time series observations by series_id.
Returns date + value rows enriched with series metadata (title, units).
Optional observation_start bounds the window (YYYY-MM-DD).
"""
sid = series_id.strip()
if not sid:
raise InvalidParameterError("fred", "series_id must be non-empty")
# ... call the API, build a DataFrame ...
df["date"] = pd.to_datetime(df["date"]) # OutputSpec never coerces —
df["value"] = pd.to_numeric(df["value"]) # cast in the connector body.
return df # raw DataFrame, never a Result
Three facts make this a connector:
- It is a sync
defwhose parameters are flat, top-level scalars. There is no publicparams: BaseModelsurface — the framework builds the envelope from the bare function signature. - It returns raw data — a DataFrame, Series, scalar, or dict — never a
Result, never a(data, provenance)tuple. The framework constructs theResult. - It carries a description (the docstring here, or
description=on the decorator). The description is the agent-facing capability statement.
The three decorators¶
All three live in parsimony and decorate a synchronous def. Pick by what the
function produces.
@connector¶
The general-purpose decorator. Use it for almost everything: time-series fetches, native-search wrappers, dict/scalar lookups, anything that does not fit the stricter loader or enumerator shapes.
@connector(output=..., tags=[...], secrets=(...), requires=(...))
def provider_fetch(...) -> pd.DataFrame: ...
secrets= and requires= are the two independent credential declarations —
parameters to redact vs env vars the call needs; see
credentials.
output= is optional. When present, it is attached to the result as
result.output_spec unchanged — the framework never inspects, reorders, or
filters your returned DataFrame against it. Any column you return that the
schema doesn't name simply exists on result.raw, undeclared. When output=
is absent, the framework still wraps the return value; the result simply has
no column governance.
@enumerator¶
For catalog discovery — a function that lists the entities a provider offers (series ids, dataset codes, table ids) so they can be indexed into a catalog.
The output schema must be exactly one namespaced KEY column plus one or more
TITLE columns, and no DATA columns — checked at decoration time. The
function must also be annotated -> pd.DataFrame. The decorator does not
check the returned frame's actual columns against the schema at call time —
OutputSpec is passive everywhere. That check happens later, when something
reads result.entities (or result.data) on the enumerator's
output: a declared column missing from the data raises ValueError there.
@loader¶
For functions that feed a writable data store. A loader's output must be
exactly one namespaced KEY column plus one or more DATA columns, and no
TITLE or METADATA columns.
In practice almost every "fetch" is a plain @connector, not a @loader,
because a real fetch carries a human-readable TITLE column (the series name,
the dataset label), which a loader schema forbids. Reach for @loader only when
the output is pure keyed data with no titling.
See discovery and catalogs for how enumerators feed catalogs, and building catalogs for the end-to-end build.
The output schema¶
The schema is declarative and passive: you describe the roles of columns, and that's the whole job. The framework never applies it to the data — no dtype coercion, no renaming, no reordering, no dropping. It only labels columns so a later consumer (the LLM card, a catalog's entity projection, a data store) knows what each one means.
from parsimony.result import OutputSpec, Column, ColumnRole
OutputSpec(columns=[
Column(name="series_id", role=ColumnRole.KEY, namespace="fred"),
Column(name="title", role=ColumnRole.TITLE),
Column(name="units", role=ColumnRole.METADATA),
Column(name="date", role=ColumnRole.DATA),
Column(name="value", role=ColumnRole.DATA),
])
Column roles¶
| Role | Meaning | Multiplicity |
|---|---|---|
KEY |
The entity identity — series id, dataset code, table id. | At most one per schema. |
TITLE |
The human-readable name of the entity. | At most one per schema. |
METADATA |
Descriptive attributes (units, frequency, dates). | Any number. |
DATA |
The observations themselves (a date, a value). | Any number. |
There is no minimum-column requirement at declaration time — an OutputSpec
with zero columns is valid. The stricter decorators (@loader, @enumerator)
layer their own multiplicity requirements on top; see The three decorators.
Column options¶
namespace=is allowed only on aKEYcolumn, and only needs to be non-empty when set. It may be omitted at declaration time (e.g. a per-call dynamic namespace); it becomes mandatory only when something actually projects entities from the result (result.entities/result.data) — or, eagerly, at decoration time for@loaderand@enumerator, since their whole point is to feed a store/catalog.- There is no
dtype=—Columnhas no dtype field. Cast values yourself, in the connector body, before you return (pd.to_datetime,pd.to_numeric,.astype(...)). Column(name="*")is a wildcard, meaningful only at entity-projection time: it claims every DataFrame column not otherwise taken by an explicit KEY/ TITLE/DATA/METADATA entry.exclude_from_llm_view=Truekeeps a column in the data payload but out of the agent-facing view. It is rejected onDATAandTITLEcolumns (those must always be visible).
Result and provenance¶
The framework wraps every connector return value in a parsimony.result.Result.
Connectors never construct one. A Result carries:
raw— the payload (DataFrame, scalar, dict, …), exactly as returned — never copied, coerced, renamed, or reordered.output_spec— theOutputSpec, when one was declared, attached unchanged.provenance— aProvenancerecordingsource(the connector name),source_description,fetched_at(UTC), andparams(the call arguments, minus anything declared insecrets=and minus bound arguments). Oversized provenance fields are replaced with a structured marker, never a truncated prefix that could leak the head of an unredacted secret.
Accessors¶
| Accessor | Returns |
|---|---|
result.frame |
The DataFrame; raises TypeError if the payload is not tabular. |
result.raw |
The raw payload (used for dict/scalar results). |
result.is_tabular |
Whether raw is a DataFrame. |
result.text |
The payload as a string. |
result.columns |
The declared Column list (empty when no schema). |
result.entities / result.data |
Parallel lazy EntityRef-keyed projections — identity (Entity) and DATA-column frames respectively — built from output_spec against raw — see discovery and catalogs. |
result.to_llm(max_rows=..., max_chars=...) |
A bounded, governed string view for agent context — honest row/column counts, hidden columns dropped, first N rows. |
Descriptions and tags¶
A description is required: 20–800 characters after stripping. It comes from
the docstring or description=. It is the agent-facing capability statement — an
agent decides whether to call the connector based on it. Lead with a clear first
sentence that states what the call returns.
tags= are free-form labels ("macro", "us", ...) used to organize connectors
and to filter a collection (connectors.filter(tags=["macro"])). They carry no
special meaning to the framework.
The loader and enumerator decorators inject a "loader" / "enumerator"
tag automatically; do not add those by hand.
Conformance: the merge gate¶
parsimony.testing.assert_plugin_valid(module) runs the six checks every
official plugin must pass. parsimony list --strict runs them across all
installed plugins, and they are the gate for merging a connector.
- CONNECTORS exported — the module exposes
CONNECTORS: Connectors, a non-empty collection. - Descriptions in bounds — every connector's description is 20–800 chars.
- Enumerator uses the real decorator — an
enumerator-tagged connector must be built with@enumerator, not@connector(..., tags=["enumerator"]). - Enumerator return type — enumerators declare
output=and annotate apd.DataFramereturn. - Flat public params — no bundled
params: BaseModelon the public surface. - Secrets declared — any credential-shaped parameter (
api_key,token,*_key, …) must appear insecrets=.
Wire these into a plugin's tests by subclassing ProviderTestSuite and setting
module or module_path; pytest then runs assert_plugin_valid for you.
See errors for the typed-error model a connector raises, and using connectors for the consumer side.