Protocol Reference

Every stdin/stdout line is one JSON object with required code.

Interface Boundary

This protocol is the only runtime interface.

Agent-facing reliability guarantees:

Input (stdin)

query

Execute one SQL statement.

FieldRequiredDescription
codeyes"query"
idyesclient correlation id
sessionnosession id; default session if omitted
sqlyesSQL text
paramsnopositional bind values
optionsnoquery behavior

options fields:

FieldDefaultDescription
stream_rowsfalsestream rows as result_rows events
batch_rows1000max rows per result_rows event
batch_bytes262144soft byte target per streamed batch
statement_timeout_msconfig defaultper-query statement timeout
lock_timeout_msconfig defaultper-query lock timeout
permissionnative/pipe transport defaultread, write, ssh-read, ssh-write, container-read, or container-write
inline_max_rowsconfig defaultinline row cap for non-streaming
inline_max_bytesconfig defaultinline payload bytes cap for non-streaming

In native CLI and pipe mode, permission defaults to read for direct sessions, ssh-read for sessions using afpsql SSH transport, and container-read for sessions using afpsql container transport. Read permissions run in PostgreSQL read-only transactions. Direct writes require write; SSH writes require ssh-write; container writes require container-write.

Parameter Binding Rules

  1. Dynamic values should be passed via params with $1..$N placeholders.
  2. Placeholder count must equal params length (validated from prepared-statement metadata, not SQL text scanning).
  3. Client-side count/shape/local binding conversion failures return error_code: "invalid_params".
  4. PostgreSQL server conversion/execution failures return code: "sql_error" with the original SQLSTATE.

Driver-side type mapping (prepared statement parameter OIDs):

Unsupported:

CLI mapping notes:

config

Partial runtime config update. Echoes full config afterward.

FieldRequiredDescription
codeyes"config"
default_sessionnodefault session name
sessionsnosession connection definitions
inline_max_rowsnoglobal inline row limit
inline_max_bytesnoglobal inline payload bytes limit
statement_timeout_msnoglobal statement timeout
lock_timeout_msnoglobal lock timeout
lognoenabled log categories

Session connection shape supports:

Supported TLS settings supplied in dsn_secret or conninfo_secret are honored. afpsql currently accepts sslmode=disable/prefer/require; unsupported libpq TLS modes/options such as verify-ca, verify-full, sslrootcert, sslcert, and sslkey fail with structured errors and hints.

SSH transport fields start a local OpenSSH tunnel or Unix-socket bridge before connecting. They currently expect discrete connection fields rather than dsn_secret or conninfo_secret.

Container transport fields start a no-TTY exec bridge through the selected driver (docker, podman, nerdctl, compose, or kubectl) and run a small stdio bridge inside the container. The PostgreSQL host/port or Unix socket is interpreted inside the container. Container transport can use dsn_secret, conninfo_secret, or discrete connection fields.

Container driver scope is configured with named fields, not raw argv passthrough: container_context applies to Docker and kubectl, container_namespace applies to kubectl, and container_compose_files / container_compose_project apply to Compose. container_pod_container applies to kubectl multi-container pods and is emitted as -c CTR before --. AFPSQL_CONTAINER_COMPOSE_FILE may supply colon-separated Compose files when no container_compose_files are configured.

When both ssh and container are set, afpsql uses SSH to run the container exec command on the remote host, then bridges from inside the container. In this combined mode, only ssh and ssh_options apply; SSH tunnel and sudo bridge fields are for non-container SSH transport. The permission family remains container (container-read / container-write).

CLI translation notes:

cancel

Cancel a queued or running query by id.

{"code":"cancel","id":"q-123"}

When the database connection is already executing the query, afpsql sends a PostgreSQL server-side cancel request. When the query is still queued, afpsql removes it before execution. Cancellation is still race-prone: a query may finish normally before the cancel request is processed.

ping

Health check.

{"code":"ping"}

close

Graceful shutdown.

{"code":"close"}

session_info

Pipe-mode introspection request. Returns the named session’s resolved transport, permission default, and runtime limits so an agent can discover what it is connected to without probing via failing queries.

FieldRequiredDescription
codeyes"session_info"
idnoclient correlation id
sessionnosession id; default session if omitted

Unknown session names return code:"error" with error_code:"invalid_request" and a hint pointing to config.

begin / commit / rollback

Pipe-mode explicit transactions. Without these, every query is wrapped in its own implicit BEGIN..COMMIT, so multi-statement atomicity requires jamming everything into one SQL string. After begin, subsequent query events on the same session run inside the open transaction until a matching commit or rollback.

{"code":"begin","id":"b1","session":"default","read_only":false,"permission":"write"}
{"code":"commit","id":"c1","session":"default"}
{"code":"rollback","id":"rb1","session":"default"}
FieldRequiredDescription
codeyes"begin", "commit", or "rollback"
idnoclient correlation id, echoed on the response
sessionnosession id; default session if omitted
read_onlyno, begin onlywhen true, send BEGIN READ ONLY
permissionno, begin onlyrequired when read_only:false on a session whose default permission is read; write / ssh-write / container-write

The response is a code:"result" event with command_tag set to "BEGIN", "COMMIT", or "ROLLBACK". Failures (e.g. begin while already in a tx, commit with no open tx, or PostgreSQL errors) surface as error or sql_error.

Per-query failures inside an explicit transaction are wrapped in a savepoint and rolled back individually, so the user’s outer transaction is NOT aborted by a single bad query — the agent can retry or move on without losing prior progress. Send rollback to discard the whole transaction or commit to persist the work done so far.

Tx control runs through the same session FIFO as query, so the order an agent writes events to stdin is the order PostgreSQL sees them.

Output (stdout)

result

Small result returned inline.

FieldDescription
code"result"
idquery id
sessionsession used
command_tagNormalized command tag (ROWS N / EXECUTE N)
columnscolumn metadata array
rowsresult rows
row_countrow count actually emitted (the prefix size when truncated)
truncatedoptional; true when rows is a prefix of the full result
truncated_at_rowsoptional; inline row cap that fired
truncated_at_bytesoptional; inline byte cap that fired
tracetiming and counters

When truncated: true, the underlying SQL still executed in full. For UPDATE ... RETURNING, this means the writes happened and the RETURNING projection delivered to the agent is the first N rows. To collect the full result, narrow the query with WHERE or switch to --stream-rows.

result_start

Start of streamed result.

FieldDescription
code"result_start"
idquery id
sessionsession used
columnscolumn metadata

result_rows

One streamed row batch.

FieldDescription
code"result_rows"
idquery id
rowsrow objects for this batch
rows_batch_countrows in batch

result_end

End of streamed result.

FieldDescription
code"result_end"
idquery id
sessionsession used
command_tagNormalized command tag (ROWS N / EXECUTE N)
traceincludes duration_ms, row_count, payload_bytes

dry_run

Emitted instead of executing the SQL when --dry-run is passed. The server prepares the statement inside a transaction that is rolled back, so this also validates table/column existence and placeholder counts without side effects.

FieldDescription
code"dry_run"
idoptional client correlation id
sqlthe SQL that would have been executed
paramsthe params that would have been bound, in JSON-encoded form
sessionsession that would have been used
param_typesinferred PostgreSQL types for $1, $2, … in placeholder order
columnsoutput column metadata (empty for non-SELECT statements)
tracetiming and counters

If preparation fails, afpsql emits sql_error (PostgreSQL diagnostic) or error (placeholder-count mismatch / connect failure) with the same shape as a normal query, and exits non-zero.

sql_error

Database execution error.

FieldDescription
code"sql_error"
idquery id
sessionsession used
sqlstateSQLSTATE (23505, 42P01, …)
messageprimary error message
detailoptional detail
hintoptional hint
positionoptional SQL character position
tracetiming and counters

error

Client/runtime/protocol error.

FieldDescription
code"error"
idoptional related query id
error_codemachine-readable code
errorhuman-readable detail
sqlstateoptional SQLSTATE when PostgreSQL rejects connection setup
messageoptional PostgreSQL primary message for connection setup failures
detailoptional PostgreSQL detail for connection setup failures
hintoptional remediation hint
retryablewhether retry may succeed
tracetiming and counters

Canonical error_code values:

For connection setup failures, code remains "error" and error_code remains "connect_failed". If PostgreSQL returns a server diagnostic during startup (for example password auth failure, missing role/database, too many connections, or cannot-connect-now), afpsql also includes sqlstate plus PostgreSQL diagnostic fields and a SQLSTATE-specific hint.

session_info

Response to a session_info request.

FieldDescription
code"session_info"
idoptional client correlation id
sessionresolved session name
transport_kind"direct", "ssh", or "container"
permission_defaulttransport-default permission ("read", "ssh-read", or "container-read")
stream_rows_defaultsession’s default stream_rows value
batch_rowsresolved batch_rows default
batch_bytesresolved batch_bytes default
inline_max_rowsresolved inline row cap
inline_max_bytesresolved inline payload byte cap
statement_timeout_msresolved statement timeout
lock_timeout_msresolved lock timeout
databaseoptional PostgreSQL database name (from probe or config)
useroptional PostgreSQL role (from probe or config)
hostoptional server host (from probe or config)
portoptional server port (from probe or config)
server_versionoptional PostgreSQL server version (from probe)
tracetiming and counters

If the probe SELECT succeeds during session_info, database/user/host/ port/server_version reflect what the PostgreSQL server itself reports. If the probe fails (typically because connection setup itself fails), the fields fall back to the resolved session config and server_version is omitted. Probe failures do not cause session_info to error.

Other output codes

codeMeaning
configfull runtime config echo
pongping response with counters
closeshutdown acknowledgement
logoptional runtime diagnostic event (enabled by log config/categories)

log event fields:

Startup log events include version, parsed/summarized args, and selected environment fallback presence metadata (key plus present). They intentionally omit raw argv, raw environment values, and config snapshots. Bind values are summarized as param_count, not logged as plaintext.

log category matching (from config.log / --log):

transport.selected is emitted once when a new session connection is opened and the transport log category (or all / *) is enabled. Its chain summarizes the selected boundary, for example ssh:user@server -> docker exec pg -> tcp 127.0.0.1:5432.

mode.permission_default_changed is emitted under the mode log category whenever --mode psql bypasses the native read-only default, so agents can see when psql-compat translation has dropped the write boundary.

connect.libpq_env_fallback is emitted under the connect log category when libpq PG* environment variables (PGHOST, PGPORT, PGUSER, PGDATABASE, PGPASSWORD, PGSSLMODE) fill connection fields that were not provided via flags or secrets, listing which variables were used.

Runtime Safety Limits

Pipe mode applies hard protocol limits before executing a request:

Environment Fallback

Optional runtime fallback variables:

Standard PostgreSQL environment fallback (lower precedence):

Example: Small Result

Input:

{"code":"query","id":"q1","sql":"select 1 as n"}

Output:

{"code":"result","id":"q1","command_tag":"ROWS 1","columns":[{"name":"n","type":"int4"}],"rows":[{"n":1}],"row_count":1,"trace":{"duration_ms":2}}

Example: Streamed Result

Input:

{"code":"query","id":"q2","sql":"select * from big_table where id > $1","params":[100],"options":{"stream_rows":true,"batch_rows":1000}}

Output:

{"code":"result_start","id":"q2","columns":[{"name":"id","type":"int8"},{"name":"name","type":"text"}]}
{"code":"result_rows","id":"q2","rows":[{"id":101,"name":"a"},{"id":102,"name":"b"}],"rows_batch_count":2}
{"code":"result_end","id":"q2","command_tag":"ROWS 200000","trace":{"duration_ms":443,"row_count":200000,"payload_bytes":34199211}}