One engine. Many front doors.
Your data is reachable through the protocols your tools already speak, and your applications are reachable over REST, a realtime socket and the Model Context Protocol at the same time.
What this page covers #
PostgreSQL wire protocol
Query the lakehouse with any Postgres client or BI tool, as if it were Postgres.
- Standard drivers and existing dashboards connect directly
- Analytics run over the live lakehouse, with no warehouse to load
Arrow Flight SQL
Columnar, high-throughput analytical access for data tools.
- Results stream as Arrow record batches
- Built for dataframe libraries and analytical engines
Graph queries
Traverse tabular data as a connected graph, without maintaining a graph database.
- ISO GQL, Bolt-compatible clients
- Relationships inferred from the live schema
REST, WebSocket and MCP
Every application exposed identically over all three, with live updates.
- Generated REST facade over every query and mutation
- Live queries: snapshot, then diffs, as data changes
Query the lakehouse #
Documents, structured records and synced external systems all resolve as tables. Analytics run against the live lakehouse: fresh writes are folded in transparently, so a query never returns a stale answer while it waits for a background flush.
- PostgreSQL wire protocol
- psql, JDBC and ODBC drivers, notebooks and dashboards connect as if to Postgres.
- Arrow Flight SQL
- Columnar, high-throughput access. Results stream as Arrow record batches.
- Graph queries
- Read-only ISO GQL, Bolt-compatible traversal over the same tables.
- Natural language
- A planner turns a plain-language question into permission-aware SQL, with citations back to the rows it used.
$ psql "postgresql://[email protected]:5432/operations"
=> SELECT site,
count(*) AS open_findings
FROM inspections
JOIN findings USING (inspection_id)
WHERE findings.status = 'open'
GROUP BY site
ORDER BY open_findings DESC
LIMIT 20; Documents, structured records and synced external systems are all queryable as tables. Your dashboard tool connects with the same string.
-- 'contracts' is a governed lakehouse table.
-- 'erp' is a Postgres database nobody migrated, registered as a source;
-- its tables resolve as an ordinary three-part reference.
SELECT c.customer_id,
c.signed_at,
a.owner
FROM contracts AS c
JOIN "erp"."public"."accounts" AS a USING (customer_id)
WHERE c.signed_at >= now() - interval '90 days'
ORDER BY c.signed_at DESC; One planner, two systems. The predicate is pushed down to the external database in its own SQL dialect, the external side stays read-only, and the row filters and column masks that apply to the lakehouse table apply to the whole query.
A driver connection is not a way around governance. The filters run inside the query, not in front of it.
Call an application #
An application declares its operations once. From that declaration the platform generates a REST endpoint, a realtime target and an MCP tool, all in auth parity, plus the typed client that calls them.
curl https://agentycs.example.com/api/apps/inspections/open_findings \
-H "Authorization: Bearer $AGENTYCS_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"site_id": "FAW-014"}' Authenticated application traffic lives under /api/apps/{app}/. Public routes an app chooses to expose live under /api/public/apps/{app}/. The operation name is the one the application declared.
curl -N https://agentycs.example.com/api/apps/inspections/open_findings \
-H "Authorization: Bearer $AGENTYCS_API_TOKEN" \
-H "Accept: text/event-stream"
event: snapshot
data: {"rows": [{"site": "Fawley", "open_findings": 12}]}
event: diff
data: {"changed": [{"site": "Fawley", "open_findings": 11}]} One operation, two reading modes. Ask for an event stream and you get the current answer once, then only what changes. The same subscription is available over the realtime socket.
const socket = new WebSocket("wss://agentycs.example.com/ws");
socket.addEventListener("open", () => {
socket.send(JSON.stringify({
type: "query",
target: "inspections.open_findings",
params: { site_id: "FAW-014" },
}));
}); Realtime targets are app-scoped, written as app.target. Browser sessions authenticate from the HttpOnly session cookie, never from a token in the URL.
Large answers and live answers #
Analytical results stream as record batches under a strict terminal contract, so an arbitrarily large answer arrives incrementally rather than being buffered whole. Application subscriptions work the other way round: you receive the current answer once, then only the diffs, as the underlying data changes.
- Streaming results with an explicit terminal state, never a silent truncation
- Live queries delivered as snapshot then diff, over server-sent events or the socket
- Retries only for transient failures, and only on idempotent methods
When something goes wrong #
Failures are classified rather than collapsed into a generic 500, because the class is what tells you whether to retry, to fail over or to page someone. Every response carries x-agentycs-request-id, success or failure, and every failure adds x-agentycs-error-code naming the class below.
| What happened | Status | Safe to retry? |
|---|---|---|
| No credential, or one that is invalid, revoked, expired or disabled | 401 unauthorized | No. Get a new credential. |
| A valid identity, but the per-operation permission check denies it | 403 permission_denied | No. The answer will not change on its own. |
| The application did not answer inside the gateway's deadline | 504 | Yes, on an idempotent method. |
| A transport, decode or forwarding failure between gateway and application | 502 | Yes, on an idempotent method. |
| The circuit breaker is open and the gateway is shedding load | 503 | Yes, after a backoff. |
| The authorisation backend itself is momentarily unavailable | 503 | Yes. This is explicitly not a deny. |
Why a transient auth failure is never a deny
Collapsing an unreachable authorisation service into 403 would teach every client that a temporary outage is a permanent answer, and clients cache that. Deterministic outcomes — invalid, revoked, expired, disabled — deny. A backend that could not be reached returns a temporary status, so a retry is the correct behaviour rather than a bug.
Correlating a failure back to the request
The request id on the response is the same id carried through the gateway, the application runtime and the audit record, so a support conversation starts from one identifier rather than a timestamp and a guess. Quote it and the trace, the logs and the access record all resolve to the same request.
$ curl -i https://agentycs.example.com/api/apps/inspections/close_finding \
-H "Authorization: Bearer $AGENTYCS_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"finding_id": "FAW-014-22"}'
HTTP/2 403
x-agentycs-error-code: permission_denied
x-agentycs-request-id: 01JQ8Z5W3K4M6P8R0T2V4X6Y8A A valid identity that is not allowed this operation. The class is in the header, not buried in a message, so a client can tell a denial from an outage: 403 will not change on retry, while a 503 from the same endpoint means the authorisation backend was briefly unreachable and a retry is correct. Quote the request id and the trace, the logs and the access record all resolve to this call.
The same rules on every path #
A direct driver connection is not a way around governance. Every path shares one planner and one set of results, so the protocol you pick is a matter of which client you happen to be holding. Row filters, column masks and deny decisions are evaluated during execution, and every read lands in a redacted audit record correlated to the identity behind it.
- Read-only by default, with mandatory limits and a bounded cost per query
- Filtering and masking applied identically to SQL, Flight, graph and REST
- Workspace scope derived from validated session or token state, never a client hint
- Per-operation permission checks on application queries and mutations
-- Signed in as a regional analyst.
=> SELECT count(*) FROM findings;
count
-------
1284
-- Same instance, same table, same SQL, signed in as a site contractor.
=> SELECT count(*) FROM findings;
count
-------
112 Nobody wrote a WHERE clause. The filter is part of query execution rather than a layer in front of it, which is why a direct driver connection reaches the same answer as the application does, and no more.
Keep going #
OpenAI-compatible API
Point the SDKs, agent frameworks and editors you already run at the models your own instance serves.
Open →Connectors
Sync the document sources your teams already use, or register an external database and query it in place.
Open →MCP and tools
Expose the same operations as agent tools, each one still passing a per-operation permission check.
Open →