Authoring for agents
A quick-reference map of the pz authoring surface for an agent working through the pz MCP
server’s tools. Everything here is convenience, not ground truth: it exists to help you write
correct YAML/SQL on the first try and to know which tool to reach for next. If anything here ever
disagrees with what a tool call actually returns — a compile error, a next_step hint, a
pz_project_overview result — the tool is right and this page is stale. A client that cannot read
MCP resources at all loses nothing it needs: every rule, error code, and next step below is also
what the tools themselves report.
Project anatomy
Section titled “Project anatomy”A pz project is a directory of YAML and SQL, no registration lists:
my-project/├── project.yml # identity, connector requirements, engine config, vars├── connections.yml # every place pz talks to, and each place's entities├── pipelines/│ ├── stg_orders.sql│ ├── orders_enriched.sql│ └── configs/│ └── orders_enriched.yml # optional sidecar: materialization, tags, checks└── .pz/ # generated, gitignored — compiled SQL, run history, watermarks-
project.yml— project name/version, theconnectors:list (NuGet package + version each connector ships as),vars:(referenced from SQL viavar('name')), andengine:tuning. Minimal example:name: pz_new_projectversion: 0.1.0connectors:- package: Pz.Connector.LocalFilesversion: 0.1.0vars:min_amount: 10 -
connections.yml— one top-level YAML key per connection (a place with credentials); there is noconnections:wrapper and no nestedconnection:key. Connector options sit directly under the connection’s name. A connection may declareentities:for the things (tables, files, endpoints) that live there:crm:connector: localfilesentities:orders:read:path: data/orders.csvformat: csvcolumns:id: bigintcustomer_id: bigintamount: doublestatus: varcharlake:connector: localfilesroot: out # a source() write lands under out/<entity>/ unless overridden per entityformat:on a file-place entity (localfiles, s3, gcs, azureblob, sftp) is one ofcsv,tsv,parquet,json,xlsx,avro. Format-scoped options sit beside it, in YAML or assource()/sink()kwargs:delimiter: "|"(csv only, one ASCII character; tsv is fixed to tab),layout: ndjson | array(json only;ndjsonis the default,arrayis one top-level JSON array and is native-tier only, so sftp refuses it with PZ0361). An option on the wrong format is PZ0362. A read with nopath:is<root>/<entity>.<format>; a sink file is<entity>.<format>.xlsx(read and write, optionssheet:andheader:, one workbook per entity) andavro(read only) use DuckDB’sexcelandavroextensions, installed on first use (needs network once per machine and DuckDB version, cached under~/.duckdb/extensions/); both are native-tier only, so sftp refuses them with PZ0361. Withheader: falseDuckDB names xlsx columnsA1,B1, …, so acolumns:contract must use those names. xlsx write is localfiles-only (an s3/gcs/azureblob xlsx sink is PZ0361); xlsx read works on all four.An entity does not have to be declared in YAML at all — see Two surfaces, one declaration below.
sources:/sinks:directories and a top-leveloutputs:block are retired (PZ0346/PZ0347); everything is oneconnections.yml. -
pipelines/*.sql— one pipeline per file, named after the file. Extract is{{ source(...) }}/{{ ref(...) }}in theFROM; transform is theSELECT; load is an optional leadingINSERT INTO {{ sink(...) }}. A pipeline with noINSERT INTOis an intermediate — it stays valid as long as another pipelineref()s it. -
pipelines/configs/<name>.yml— optional sidecar matched by pipeline name, formaterialization(table/view/ephemeral),tags, and data-qualitychecks:pipelines/configs/orders_enriched.yml pipeline: orders_enrichedmaterialization: tabletags: [daily, crm]checks:- not_null: [id, email]- unique: [id]Other check types:
row_count: { min, max },freshness: { column, max_age },accepted_values: { column, values },custom_sql: { name, sql }. Checks are observational — a failing check fails the run but never blocks the pipeline’s own sink write.
Two surfaces, one declaration (PZ0341)
Section titled “Two surfaces, one declaration (PZ0341)”Every read or write option can be declared either under connections.yml’s
entities: <e>: read:/write:, or as a keyword argument on the source()/sink() call that
uses it — never both. There is no merge of the two; a reader of one file sees the whole story
for that entity-side. Kwarg names equal YAML keys at every nesting level, so moving an option
between the two surfaces is cut-and-paste. Declaring the same entity-side in both places is
PZ0341.
Read, declared at the call site:
join {{ source('crm', 'customers', path: 'data/customers.csv', format: 'csv') }} as cRead, declared in YAML instead (connections.yml):
crm: entities: customers: read: path: data/customers.csv format: csvWrite, declared at the call site:
INSERT INTO {{ sink('lake', 'orders_curated', format: 'parquet', strategy: 'replace') }}Write, declared in YAML instead:
lake: entities: orders_curated: write: format: parquet strategy: replaceAn entity that appears in neither connections.yml nor as a bare name is not an error — a
source()/sink() call that names it is the declaration. Only an unknown connection is an
error (PZ0201).
The template functions
Section titled “The template functions”Pipeline SQL is Scriban in sandboxed mode with a small whitelisted function set — no file I/O, no network, no wall clock:
| Call | Does |
|---|---|
source('<connection>', '<entity>', **kwargs) |
Reads an entity, resolves to a staging table name, declares a DAG edge. **kwargs are read options per the two-surfaces rule above. |
ref('<pipeline>') |
Reads another pipeline’s output, resolves to its staging table, declares a DAG edge. Takes no other arguments. |
sink('<connection>', '<entity>', **kwargs) |
Must be the pipeline’s leading INSERT INTO marker (whitespace/comments before it are fine). Records the load binding. **kwargs are write options per the two-surfaces rule above — strategy, keys, duplicates, on_delete, schema_policy, retry are pz’s own names; anything else passes through to the connector unchecked. The call must fit on one line — a sink()/source() split across lines is a template parse error (PZ0104). |
watermark('<connection>', '<entity>') |
Renders the entity’s stored incremental cursor value for a comparison in WHERE. Declares the entity incremental in SQL — see below. Creates no DAG edge (the paired source() call already does). |
A pipeline can feed more than one sink by passing a list of markers instead of one:
INSERT INTO [{{ sink('a', 'x') }}, {{ sink('b', 'y') }}] — the query runs once, drains to every
listed output.
One reader per source (PZ0349)
Section titled “One reader per source (PZ0349)”A source entity is read by exactly one pipeline. Two different pipelines calling
source('crm', 'orders') is a compile error (PZ0349) — to share a dataset, ref() the pipeline
that reads it, not source() it a second time. This is also why a SQL-declared incremental’s
WHERE clause is the whole story for that dataset: there is no second reader that could disagree
about the cursor.
Incremental reads: watermark()
Section titled “Incremental reads: watermark()”Declare a source entity incremental by comparing its cursor column against
{{ watermark('<connection>', '<entity>') }} in the pipeline’s WHERE clause — no columns:
contract or sync: block required for a plain floor:
INSERT INTO {{ sink('mart', 'mart.orders_current', strategy: 'merge', keys: ['order_id']) }}select order_id, customer_id, amount, status, updated_atfrom {{ source('erp', 'dbo.orders', partition_column: 'order_id', partitions: 4, retry: { max_attempts: 3 }) }}where updated_at > {{ watermark('erp', 'dbo.orders') }}The recognized shape is an ordered comparison — <cursor column> followed by >, >=, <, or
<=, then an expression containing exactly one watermark() call and no column references
(flipped spellings, expression-then-operator-then-column, mean the same and are also accepted).
>/>= are floors (the resume point); </<= are ceilings. Anything else — =/!=, a function
on the cursor side, a column on the value side, a bare watermark() outside a comparison — is
PZ0224.
Bounded windows still need a columns: contract. A plain floor (as above) needs no contract at
all — the cursor’s type is discovered from the stored watermark or, on the first run, from what the
bound expression evaluates to. But the initial/max_window/until trio (written in SQL as a
coalesce(...) floor plus a least(...) ceiling, or declared in YAML sync:) must be able to
compute its bounds before the first extraction — the cursor has to be typed up front, so the
entity needs a declared columns: contract (PZ0213). A contract-less csv/json entity with a
bounded window therefore still needs a hand-written columns: map for the cursor column.
Declare incrementality either in YAML (sync: { mode: incremental }) or in SQL
(watermark()), never both for the same entity — PZ0225.
Write modes and the delivery-guarantee consent rule (PZ0214)
Section titled “Write modes and the delivery-guarantee consent rule (PZ0214)”sink()’s strategy is append (default), replace, or merge (needs keys: [...]). An
incremental source feeding an append sink is at-least-once by construction (a re-run or
retry can re-deliver a slice) — pz refuses that pairing at compile time unless you say so
explicitly with duplicates: 'accept':
-- Incremental extraction paired with an append sink is at-least-once ... pz refuses this-- pairing at compile time (PZ0214) unless you consent -- which a delta log deliberately does.INSERT INTO {{ sink('lake', 'issues_log', format: 'parquet', path: 'out/issues/', strategy: 'append', duplicates: 'accept') }}select id, number, title, state, updated_atfrom {{ source('github', 'issues') }}An incremental source feeding replace is refused outright (PZ0335, no consent escape — a
partial extraction would silently truncate the target). merge is effectively-once regardless.
Out-of-process connectors (PCP)
Section titled “Out-of-process connectors (PCP)”Every external (non-builtin) connector package hosts its connector as a separate OS process —
in-process loading is reserved for builtins — declared in the package’s pz.connector.json:
runtime: "process"— required for external packages; a package declaring"dotnet"(or shipping no manifest, which means the same) is refused withPZ0360.entrypoints— a RID → package-relative binary path map, e.g.{"linux-x64": "runtimes/linux-x64/native/pz-mysink", "win-x64": "runtimes/win-x64/native/pz-mysink.exe"}. Resolved withRuntimeIdentifierGraphfallback (a package shipping onlylinux-x64is still reachable fromlinux-musl-x64), and rejected if a path would resolve outside the package directory.sdk: {name, version}— which SDK built the connector and at what version, distinct from the connector’s ownname/entrypoint version. Additive: absent on a manifest written before this existed, which reads as an unnamed SDK, never a handshake disagreement. Both SDKs’ Hello echoes the same name/version, andpz connectors/pz connector test/PZ0356/PZ0357 show it.- The host masks
CheckpointableReads,CheckpointableWrites, andChangeCaptureuntil they are wired over the wire; declared flags whose ABI interface the host shim does not implement (StreamingPartitions) take the materialized path.SyncState(opaque-token feeds) is honored: the connector answersGetNaturalReadShape(FEED/FULL per dataset, plan-time, offline) andGetReadState(the partition’s token, pulled by the host after the drain completed). A connector that never implementsGetNaturalReadShapereads as FULL. A FEED dataset always takes the arrow path: the token is captured from the drained partition, which a native scan never drains, so the planner routes around any native scan the connector offers for it (a native-only connector is refused,PZ0363).
This is packaging-time detail an agent authoring connections.yml/pipelines never touches
directly — the connector’s connector: name in connections.yml and its ConnectionConfigSchema/
DatasetConfigSchema (from pz_connector_reference) look identical either way.
allow_unsigned_extensions: — a connection-level key, alongside connector/entities/
max_concurrency/rate_limit/retry, default false. A native scan/copy that would load an
unsigned packaged DuckDB extension is refused at plan time (PZ0359) unless the connection sets
allow_unsigned_extensions: true.
Writing one in C# — Pz.Connectors.Sdk. Implement ISourceConnector and/or ISinkConnector
from Pz.Connectors.Abstractions exactly as a builtin would, then serve it with one line:
return await PzConnectorHost.RunAsync(args, new MyConnector()); (or
RunAsync(args, ctx => new MyConnector(ctx.LoggerFactory)) to log to the host). The SDK answers every
optional RPC from the interfaces your objects actually implement — a source that is not
INaturalReadShapeSource answers UNIMPLEMENTED, a partition that is not ISyncStatePartition answers
FAILED_PRECONDITION — so declare only capabilities you implement; pz connector test fails the rest.
The SDK captures the sync-state token itself when a partition’s enumeration completes, before it
writes end-of-stream: set the candidate anywhere before your iterator returns. Two argv modes only:
--pz-socket <path> (serve) and --pz-manifest --out <file> (write pz.connector.json from the
connector object); configuration never travels on argv. Packaging: the project file carries
<PublishAot>true</PublishAot> (NuGet restore never sees the SDK’s own build files, so the Native
AOT compiler pack is restored only when the project asks for it; a publish that would otherwise fall
back to a CoreCLR layout fails with PZSDK005), then dotnet publish -r <rid> per platform (Native
AOT by default; <PzPackaging>self-contained</PzPackaging> opts out and turns PublishAot back
off), then dotnet pack -p:PzNativeStaging=<dir> collects every RID under runtimes/<rid>/native/
with the generated manifest at the nupkg root — the layout pz restore already installs. Set
<PzProjectDirectoryAnchor>true</PzProjectDirectoryAnchor> when the connector resolves relative paths
in its own config; pz then passes the project directory as the base_dir connection option.
PCP error codes:
| Code | Meaning |
|---|---|
PZ0354 |
No usable entrypoint for this host: unknown runtime, no entrypoints (or none reachable for this RID), an entrypoint path that is missing or resolves outside the package directory, or a runtime: "process" manifest with no name. |
PZ0355 |
The connector executable failed to spawn (exec error, missing/non-executable binary, or no room for a socket path under the temp root). |
PZ0356 |
Handshake with the spawned connector failed: timeout waiting for Hello, malformed Hello, or a capability/name mismatch against the manifest. |
PZ0357 |
Protocol violation during data-plane operations (bad/reused write ticket, malformed Arrow IPC stream). |
PZ0358 |
The connector process died unexpectedly mid-operation. |
PZ0359 |
An unsigned packaged DuckDB extension was refused for a native scan/copy; set allow_unsigned_extensions: true on the connection to allow it. |
PZ0360 |
An external connector package declares runtime "dotnet" (or ships no manifest) — external connectors are hosted out of process only. Use a runtime: "process" (PCP) package or a builtin. |
PZ0363 |
A token-resumed dataset (feed-shaped, or sync: {mode: cdc}) sits on a native-only connector; a native scan never drains the partition the sync token is captured from, so the dataset could never advance. Use a connector with an arrow read path, or a full / cursor-incremental read. |
pz connector test <entrypoint-or-package-dir> [--config file.yml] — runs black-box PCP
protocol conformance checks against one out-of-process connector, independent of any pz project.
The target is a package directory containing pz.connector.json or a bare entrypoint binary;
--config names the connection to configure and the read:/write: dataset(s) to probe (a
connection: block plus optional read: { dataset: ... } and/or write: { output: ..., mode: ..., schema_policy: ... }). Every applicable vector runs regardless of earlier failures, printed as one
PASS/FAIL/SKIP <vector>[: detail] line each. A connector declaring SyncState additionally
runs sync-state-roundtrip (FEED shape, one sync_state partition, a non-empty token after a full
drain, and that token accepted back as prior_sync_state); it is skipped for connectors that do not
declare the flag. Exit codes: 0 every applicable vector passed, 1 one or more vectors failed,
2 a config/usage problem (bad target, malformed manifest or --config) meant no vector could even
be attempted.
Recommended tool loop
Section titled “Recommended tool loop”- Reference — read this guide and the relevant documentation page via
pz_docs_list,pz_docs_search, andpz_docs_get, or callpz_connector_referencefor the connector’s exact connection/dataset option schemas andpz_project_overviewfor what already exists in this project (connections, entities, pipelines, the compiled DAG). - Author — write or edit
connections.yml/pipelines/*.sqlby hand, or use the authoring tools (pz_add_connection,pz_add_entity,pz_write_pipeline, and theirupdate/removecounterparts). Every authoring tool self-verifies after applying and reports the resulting errors, so a bad edit is visible immediately, not just on the nextpz_compile. - Validate —
pz_compile(does the DAG build?), thenpz_validate(config/SQL sanity; passconnect: trueto also probe live connections and fetch real schemas), thenpz_plan(which per-node strategy the engine would use, and why) — cheapest checks first, each one a pure read with no side effects (nomanifest.json/plan.json/schemas.jsonwritten). - Run (only if the server was started with execution allowed) —
pz_runto execute a named flow (orall: truefor the whole project),pz_run_resultsto inspect a prior run’s node outcomes,pz_retryto re-run the last failed run reusing staged data where safe. Execution tools are absent from the tool listing entirely when the server was not started with--allow-run— there is nothing to call, not a runtime refusal.
Every tool’s error response carries a code (PZ####), a message, and a next_step — trust
that over any generic advice above when they disagree.