Reading and Writing Data from Action Code
How code inside a python / javascript (or any action) element should reach a
sql data element — and the one pattern that does not work in production,
even though older docs used to show it.
The reality: your code runs in a network-isolated sandbox
Action code executes in a Firecracker microVM on the isolation fleet. That sandbox is a deliberate security boundary: it has no internet egress and no direct route to the platform’s internal databases. This is what makes it safe to run arbitrary user code — the code can compute, but it cannot phone home and it cannot dial infrastructure directly.
A consequence that surprises people: when you attach a sql element to your
action, the injected RESOURCE_SQL_{NAME} environment variable is present
but not currently dialable from inside a production sandbox. A driver-level
connection attempt (psycopg2.connect(os.environ["RESOURCE_SQL_MY_DB"]),
pg.Client in Node, etc.) resolves DNS fine and then times out on TCP
connect after ~30 seconds — the sandbox’s network origin has no route to the
database’s connection pooler. The failure is slow and unhelpful: every invoke
burns its timeout budget and dies with a generic timeout, while the SQL element
itself answers queries instantly through its own API.
If your saves “hang”, your reads time out at exactly the invoke timeout, and a
__diag__ probe shows DNS resolving but TCP timing out — this is what you are
hitting. It is a platform limitation being worked on, not a bug in your code or
your attachment.
What works: drive the SQL element through its operations
The sql element is itself a full database API. Instead of opening a raw
driver connection from inside the sandbox, do the data work through the SQL
element’s own operations — migrate for schema, query for reads,
insert / update / delete for row work. These execute in the platform’s
runtime (outside the sandbox), so they are fast, credentialed, and work the
same in every environment:
POST /api/{circle}/{db-slug}/ops/migrate { "migration": "CREATE TABLE IF NOT EXISTS ..." }
POST /api/{circle}/{db-slug}/ops/insert { "table": "processes", "rows": [{ ... }] }
POST /api/{circle}/{db-slug}/ops/query { "sql": "SELECT * FROM processes ORDER BY created_at DESC", "params": [] }
The op contract is two-phase: schema at build time, data at runtime
Each op accepts only its own statement class, and the split is deliberate:
migrate(canonical input fieldmigration) is the only op that accepts DDL —CREATE TABLE,ALTER, indexes. It is idempotent by convention (CREATE TABLE IF NOT EXISTS ...) and belongs in the build phase: run it once when you assemble the app, right after creating the SQL element.queryis SELECT-only (SELECT / WITH..SELECT). Sending DDL or a write through it returns 400Disallowed SQL statement type— by design, so a read path can never mutate.insert/update/delete(ormigratefor bulk/parameterized DML) carry the runtime writes.
The trap this prevents: “lazy schema” — tucking a CREATE TABLE IF NOT EXISTS
into the request handler so the table appears on first use. That habit comes
from environments with one all-powerful connection; here it fails at the first
real request (the handler’s read/write op rejects the DDL), and it fails
after the app looked done, because nothing exercised the handler at build
time. So architect it as two phases from the start: when you design the app,
the schema migration is a build step (run migrate, confirm it applied,
maybe seed a row); handlers and pages then assume the schema exists and speak
only reads and writes through the matching ops.
The composition shape this leads to is also the better architecture: the flow owns data access, the action owns computation.
- A frontend or external caller needs data? Route the request to the SQL
element’s ops (directly, or through an
io/httpgateway or anautomationstep) rather than through an action that proxies to the database. - An action needs to transform data? Have the flow read the rows first and hand them to the action as input; have the flow write the action’s output back. The action stays a pure function — easier to test, no credentials inside untrusted code, and immune to the sandbox’s network boundary.
An agent building on the platform can call these ops with triform_call_op;
frontends reach them over HTTP; automations wire them as steps.
Why not just open the network route?
Because the isolation boundary is the product’s security model: sandboxed code that could dial internal infrastructure would put every circle one dependency compromise away from the platform’s data plane. If a credentialed sandbox-to-database path ships later, it will be announced in these docs; until then, treat the element-ops pattern as the way, not a workaround.
Symptoms → causes, quickly
| Symptom | Likely cause | Fix |
|---|---|---|
Invoke hangs ~30s then times out; SQL element’s own query op is instant | Driver connection from sandbox to injected DSN | Use the SQL element’s ops (this guide) |
ModuleNotFoundError: psycopg2 (or any import) | Package not declared in spec.source.requirements, or the dependency layer hasn’t finished building | Declare the package, run ops/build, wait for a terminal build state — but also reconsider whether you need a DB driver at all |
| Rows “save” but never appear | The write path never actually ran (check the run log), or the code wrote to a local file — sandbox filesystems are throwaway per-invoke | Persist through the SQL element; verify with a read-back query |
400 Disallowed SQL statement type: CREATE TABLE on query | DDL sent through the SELECT-only query op — usually lazy schema creation inside a request handler | Move the DDL to migrate as a build step; keep handlers to reads/writes via the matching ops |
In-sandbox Python SDK
The Firecracker microVM injects a triform module into every Python
invoke. The five names below are the stable contract — element
authors can import them without checking versions, and new names are
added in additive commits only.
triform.invoke(path, payload=None, timeout=30.0)
Call any element op via the platform API. Auth + circle scope are
auto-resolved, so the sandbox never needs to mint or stash a credential
of its own. Raises TriformError on any non-2xx response (status, code,
message, optional suggestion captured) and on a missing pre-configured
SDK env var (SDK_NOT_CONFIGURED). The docstring on the source:
Call any element op via the platform API. Auth + circle scope are auto-resolved.
triform.query_sql(resource, sql, params=None)
Convenience wrapper for sql elements. It calls invoke with the
element’s query op and returns the rows key — so a read path never
has to hand-build the POST /api/{circle}/{resource}/ops/query envelope
or unbox the response. The docstring on the source:
Convenience wrapper for sql elements: triform.query_sql(‘my-db’, ‘SELECT 1’).
triform.connect(resource=None, dsn=None, autocommit=False)
Open a live psycopg2 connection to an attached data element (sql /
document / vector / graph / timeseries), with search_path
pre-scoped to this circle’s schema so unqualified writes land in
circle_<uuid>, not public. The opening sentence of the docstring:
Open a psycopg2 connection to an attached data element (sql/document/ vector/graph/timeseries), with
search_pathpre-scoped to THIS circle’s schema so unqualified writes land incircle_<uuid>, notpublic.
The three argument cases match the docstring:
- omit both — auto-pick the sole attached sql resource when there is
exactly one; raise
AMBIGUOUS_SQL_RESOURCE(multiple) orNO_SQL_RESOURCE(none). resource='<slug>'— resolve the injectedRESOURCE_SQL_<SLUG>env var; raiseRESOURCE_NOT_FOUNDif unset.dsn='...'— pass an explicit connection string (overridesresource).
autocommit=True sets conn.autocommit before returning. Raises
TriformError with PSYCOPG2_MISSING when the driver is not in the
sandbox rootfs, and RESOURCE_NOT_FOUND / NO_SQL_RESOURCE /
AMBIGUOUS_SQL_RESOURCE when the target cannot be resolved.
class triform.TriformError(Exception)
The platform-side error contract. Constructor signature:
TriformError(status, code, message, suggestion=None, body=None)
The five attributes are exposed on the instance (self.status,
self.code, self.message, self.suggestion, self.body); the
__str__ returns [status] code: message. Every wrapper above — and
the underlying HTTP path — raises TriformError; except TriformError
is the supported way to catch a platform error.
triform.environment
str | None. The lane this invocation is running in
('live' / 'demo' / 'draft') when the surface stamped one, otherwise
None. The source comment calls this out:
Stable SDK contract — element code branches on
triform.environmentinstead of parsing env vars.
Branch on triform.environment instead of os.environ parsing — the
SDK is the supported adapter from the platform’s lane-per-request model
to element code, and direct env reads can drift away from the platform’s
naming.
Prefer these wrappers over raw HTTP. invoke and query_sql go through
the platform API with auto-injected auth, and connect handles the
circle-schema scoping the platform’s connection pooler otherwise
ignores. Raw urllib calls work but duplicate auth, path-prefixing,
and error normalization that these wrappers already do.
See also
- Public pages that show live data — letting anonymous visitors trigger the read path safely.
- Runs — how async invokes report status and how to poll for results.
- Isolation — why the sandbox boundary exists.