Freestyle Docs

Freestyle / Guides

How to Use an Electric SQL Client in a Sandbox

Sync Postgres data into a Freestyle VM with Electric's official client, read it from Python, and reuse a prepared runtime snapshot.

Run an Electric client inside a Freestyle VM to keep a customer’s dataset available locally. Electric streams changes from Postgres over HTTP; the client applies those changes to an in-memory collection. Agent code can then work against that collection without issuing SQL queries to the source database.

This guide uses Electric’s official @electric-sql/client package in a small Node.js service. JavaScript code can use the collection directly. Python reads a consistent copy through a localhost endpoint and works with ordinary Python dictionaries.

Postgres / Supabase
        │ logical replication

Shared Electric server
        │ authenticated, tenant-filtered HTTP shape

Freestyle VM
  Electric client → in-memory rows → localhost endpoint → Python objects

Keep the Electric server in a trusted service shared by your VMs. Each VM runs a client with access to its own authorized shape. The database password and replication connection stay on the server.

Prepare an authorized shape endpoint

You need a running Electric server connected to a Postgres database, plus an HTTPS endpoint that authorizes the VM’s requests. A shape defines which table rows Electric sends to a client.

For example, your source might contain this table:

CREATE TABLE public.items (
  id integer PRIMARY KEY,
  tenant_id text NOT NULL,
  value integer NOT NULL
);

ALTER TABLE public.items REPLICA IDENTITY FULL;

INSERT INTO public.items VALUES
  (1, 'customer-a', 10),
  (2, 'customer-a', 20),
  (3, 'customer-b', 999);

REPLICA IDENTITY FULL includes the old row in update and delete replication records, including its previous tenant. It increases WAL volume; account for that when sizing a high-write source.

Your application’s gateway authenticates the VM token, resolves its customer, and sets the shape’s table and where parameters server-side. For customer A, these are public.items and tenant_id = 'customer-a'. The client below sends no table or tenant filter; the gateway supplies them.

Follow Electric’s proxy authorization guide for the gateway implementation. It must preserve Electric’s response status, body, and protocol headers, including electric-handle, electric-offset, and electric-schema, and forward the allowed cursor parameters. Configure its timeouts for long polling. An ordinary JSON endpoint returning database rows is not an Electric shape endpoint.

Provision these values in your local orchestration environment:

export FREESTYLE_API_KEY="your-freestyle-api-key"
export ELECTRIC_SHAPE_URL="https://api.example.com/sync/items"
export ELECTRIC_CLIENT_TOKEN="a-token-scoped-to-one-customer"

The token is issued and validated by your application. It is not the global Electric server secret or a Supabase database credential. An agent may inspect its VM’s files and memory, so the token must remain restricted even if the agent reads it. A client-supplied where clause is not an authorization boundary, and Supabase RLS does not automatically authorize Electric shape requests.

Connecting the shared server to Supabase

Electric supports hosted Supabase Postgres. Configure the shared Electric server with the direct database connection URL for logical replication, not the Supavisor pooled URL. The direct endpoint may require IPv6; configure ELECTRIC_DATABASE_USE_IPV6=true and ensure the server’s network supports it. Configure TLS and the appropriate database CA certificate for the connection.

Follow Electric’s deployment guide and Supabase’s manual replication setup for permissions and replication slots. The client VM only needs HTTPS access to your shape gateway; it does not need a direct Postgres connection, database extensions, or a replication slot of its own.

Write the client service

Save this as electric-client.mjs on your local machine, next to the orchestration script you will create below. It will be copied into the VM.

ShapeStream handles the HTTP protocol and Shape maintains the current rows, including inserts, updates, deletes, and shape resets. The HTTP server listens only on loopback. It returns 503 while the collection is loading or resynchronizing, so callers do not consume a partially loaded shape.

import { createServer } from "node:http";
import { readFileSync } from "node:fs";
import { Shape, ShapeStream } from "@electric-sql/client";

const { url, token } = JSON.parse(
  readFileSync("/etc/electric-client/config.json", "utf8"),
);
const aborter = new AbortController();
let failed = false;

const stream = new ShapeStream({
  url,
  headers: { Authorization: `Bearer ${token}` },
  signal: aborter.signal,
  onError: () => {
    // Do not log request URLs or headers: they may contain credentials.
    failed = true;
    console.error("Electric sync failed; check gateway access and token expiry.");
    // systemd will restart the process and reread its configuration.
    process.exitCode = 1;
    setTimeout(() => process.exit(1), 100);
  },
});
const shape = new Shape(stream);

const server = createServer((req, res) => {
  res.setHeader("Cache-Control", "no-store");
  res.setHeader("Content-Type", "application/json");
  if (req.method !== "GET" || !["/ready", "/data"].includes(req.url)) {
    res.writeHead(404).end(JSON.stringify({ error: "not found" }));
    return;
  }
  if (failed || shape.isLoading() || !shape.isUpToDate) {
    res.writeHead(503).end(JSON.stringify({ error: "shape is not ready" }));
    return;
  }
  const body = {
    lastSyncedAt: shape.lastSyncedAt(),
    ...(req.url === "/data" ? { rows: shape.currentRows } : {}),
  };
  // Preserve large Postgres integers as decimal strings across JSON.
  res.end(JSON.stringify(body, (_, value) =>
    typeof value === "bigint" ? value.toString() : value,
  ));
});

server.listen(8080, "127.0.0.1");
process.on("SIGTERM", () => {
  aborter.abort();
  server.close(() => process.exit(0));
  setTimeout(() => process.exit(0), 2000).unref();
});

The /ready endpoint means the client has a complete synchronized view. It is not a guarantee that every write just committed upstream has arrived. /data reports the client’s last successful sync time so the application can apply its freshness policy. Replication is asynchronous; a network outage can leave a previously complete view stale.

The Node collection is in memory. If this process restarts, it fetches the shape again. This example does not persist rows or cursors to disk, and it does not implement token issuance or automatic token renewal.

Build a reusable client snapshot

Install the Freestyle SDK locally:

npm install freestyle@latest
pnpm add freestyle@latest
bun add freestyle@latest

Create run-electric.mjs next to electric-client.mjs. The following snippets belong to the same orchestration script and share their variables. Run the completed script with node run-electric.mjs.

Build on Ubuntu, install the client, and register a systemd service. The service runs as an unprivileged user. This snapshot contains the installed runtime and code; it has no customer configuration or data, and the service is not started yet.

import { Freestyle } from "freestyle";
import { readFile } from "node:fs/promises";

const freestyle = new Freestyle();
const url = process.env.ELECTRIC_SHAPE_URL;
const token = process.env.ELECTRIC_CLIENT_TOKEN;
if (!url || !token) throw new Error("Set ELECTRIC_SHAPE_URL and ELECTRIC_CLIENT_TOKEN");
if (new URL(url).protocol !== "https:") throw new Error("Use an HTTPS shape gateway");

async function exec(vm, command, timeoutMs = 120_000) {
  const result = await vm.exec({ command, linuxUser: "root", timeoutMs });
  if (result.statusCode !== 0) {
    throw new Error(`Guest command failed (${result.statusCode}): ${result.stderr}`);
  }
  return result.stdout ?? "";
}

const egress = {
  rules: [{ action: "allow", source: {}, destination: { public: true } }],
};
const { vm: builder } = await freestyle.vms.create({
  snapshotId: "freestyle/ubuntu",
  displayName: "electric-client-builder",
  firewall: egress,
  ttlSeconds: 3600,
});
let snapshotId;
try {
  await exec(builder, "apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nodejs npm python3", 300_000);
  await exec(builder, "useradd --system --home /opt/electric-client --shell /usr/sbin/nologin electric-client && mkdir -p /opt/electric-client /etc/electric-client");
  await exec(builder, "cd /opt/electric-client && npm init -y >/dev/null && npm install --save-exact @electric-sql/client@1.5.28");
  await builder.fs.writeTextFile(
    "/opt/electric-client/client.mjs",
    await readFile(new URL("./electric-client.mjs", import.meta.url), "utf8"),
  );
  await builder.fs.writeTextFile(
    "/etc/systemd/system/electric-client.service",
    `[Unit]
Description=Electric shape client
After=network-online.target
Wants=network-online.target
ConditionPathExists=/etc/electric-client/config.json

[Service]
Type=simple
User=electric-client
Group=electric-client
WorkingDirectory=/opt/electric-client
ExecStart=/usr/bin/node /opt/electric-client/client.mjs
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target
`,
  );
  await exec(builder, "systemctl daemon-reload && systemctl enable electric-client");
  ({ snapshotId } = await builder.snapshot({
    displayName: "electric-client-runtime",
    ttlSeconds: 86400,
  }));
  console.log("Runtime snapshot:", snapshotId);
} finally {
  await builder.delete();
}

This recipe uses Ubuntu’s Node.js package and pins the Electric client version. Keep the generated package lockfile in the snapshot. If you need a particular Node release, build your runtime using How to Run Node.js in a Sandbox and update the service’s ExecStart path to match.

Start a VM for one customer

Create the customer VM from the prepared snapshot, write its configuration, and start the service. Configuration is serialized as JSON and transferred through the filesystem API; the token is never interpolated into a shell command.

const { vm } = await freestyle.vms.create({
  snapshotId,
  displayName: "customer-electric-client",
  firewall: egress,
  ttlSeconds: 3600,
});
console.log("Customer VM:", vm.id);

await exec(vm, "install -d -m 0750 -o root -g electric-client /etc/electric-client");
await vm.fs.writeTextFile(
  "/etc/electric-client/config.json",
  JSON.stringify({ url, token }),
);
await exec(vm, "chown root:electric-client /etc/electric-client/config.json && chmod 0640 /etc/electric-client/config.json && systemctl start electric-client");

let ready = false;
for (let attempt = 0; attempt < 60; attempt++) {
  const probe = await vm.exec({
    command: "curl -fsS http://127.0.0.1:8080/ready",
    linuxUser: "root",
    timeoutMs: 5000,
  });
  if (probe.statusCode === 0) {
    ready = true;
    break;
  }
  await new Promise(resolve => setTimeout(resolve, 1000));
}
if (!ready) throw new Error("Initial sync timed out; inspect the electric-client journal");

There is no inbound firewall rule and no public domain for port 8080. The service is for code inside this VM. The example allows outbound Internet access for setup and synchronization; narrow it to your application needs when configuring production sandboxes.

To rotate a token, overwrite the config file, restore its ownership and mode, and run systemctl restart electric-client. A process restart triggers a new initial sync in this example.

Read the local data from Python

Write an agent task that fetches the local collection once and works on Python dictionaries:

await vm.fs.writeTextFile("/tmp/task.py", `
import json
from urllib.request import urlopen

with urlopen("http://127.0.0.1:8080/data", timeout=10) as response:
    snapshot = json.load(response)

items = snapshot["rows"]
print("Last client sync:", snapshot["lastSyncedAt"])
print("Rows:", len(items))
print("Total:", sum(int(item["value"]) for item in items))

# Continue agent work using items. No upstream query is needed for these reads.
`);
console.log(await exec(vm, "/usr/bin/python3 /tmp/task.py"));

With the example table and customer A’s authorized shape, this prints two rows and a total of 30. Customer B’s row is excluded by the gateway’s shape definition.

This step performs a localhost HTTP request and JSON decoding once per Python task. The objects then live in that Python process. Subsequent Electric updates change the Node collection; they do not mutate the existing items list. Fetch again at a deliberate task boundary if the next task should see a newer view.

For a long-lived Python agent, keep that process alive and retain the objects between tool calls. Separate Python processes do not share object references automatically. For a JavaScript agent, Shape can live directly in the agent process, removing the localhost bridge.

This bridge copies the entire shape and serializes it on each /data request. Use it for datasets that fit comfortably in memory. Large datasets may need a local database, partitioned shapes, or a consumer that applies incremental changes directly to its target representation. Budget for both the Node collection and each Python copy.

Verify updates and recovery

On the source database, update a row visible to this customer:

UPDATE public.items SET value = 50 WHERE id = 1;

Rerun /tmp/task.py until it observes a total of 70. Allow for replication delay. Also verify inserts, deletes, and rows moving into or out of the tenant filter before relying on the integration.

Inspect the service with:

console.log(await exec(vm, "systemctl is-active electric-client"));
console.log(await exec(vm, "journalctl -u electric-client --no-pager -n 30"));

For a recovery check, stop the service, make another source update, start it, wait for /ready again, and verify the new value:

await exec(vm, "systemctl stop electric-client");
// Apply a source update through your trusted application or SQL connection.
await exec(vm, "systemctl start electric-client");
// Repeat the readiness loop before running the Python task.

The official client manages live requests and shape protocol recovery. Terminal errors in this example exit the process; systemd retries with a fresh in-memory collection. Repeated 401 or 403 failures need a corrected or renewed token, not more restarts. A gateway that strips Electric headers or buffers long polls can also prevent sync.

If your application persists rows and resumes a cursor, store the rows, shape handle, and offset consistently, and handle an expired shape by clearing the old collection and performing a full sync. Passing an old cursor to a new empty Shape does not reconstruct rows that existed before that cursor.

Choose what to snapshot

The reusable snapshot in this guide captures dependencies and code before connecting to a customer. Each new VM receives its own token and loads its own shape. This is a straightforward starting point for serving multiple tenants.

Freestyle also captures running processes and their memory, so a tenant-specific snapshot can preserve an already-loaded Python agent. That is useful when the agent should start with objects immediately available. Treat such a snapshot as a versioned customer dataset: keep it tenant-specific, define its freshness, and control who can create VMs from it.

Two operating modes require different handling:

  • Frozen task data: finish synchronization, load the Python objects, disconnect synchronization, and snapshot the prepared Python runtime. A clone uses that captured data version while it runs the task.
  • Continuously synchronized data: a clone must establish its own client connection and refresh credentials as needed. Confirm catch-up before beginning work that requires fresh data. A captured ready flag or TCP connection does not prove a restored client is current.

Do not reuse a live Electric server’s replication slot across cloned servers. Clients connect through HTTP and can share a server; they have independent request lifecycles. Snapshot contents include memory and configuration, so a broadly reusable runtime snapshot must not contain one customer’s data or a privileged source credential.

We have separately verified Electric-to-Python sync inside Freestyle VMs and preservation of a loaded Python process across VM clones. Those tests used self-hosted Supabase Postgres. Automatic catch-up of a snapshotted live client and hosted Supabase connectivity require their own end-to-end validation; the dependency snapshot recipe above does not rely on either behavior.

Clean up

When the demonstration is complete, delete the customer VM and the runtime snapshot:

await vm.delete();
await freestyle.vms.snapshots.delete(snapshotId);

Keep the runtime snapshot if you plan to reuse it, and choose an appropriate TTL. If a script fails, use the VM and snapshot IDs it printed to delete the remaining resources; the TTLs in this guide provide a fallback. The shared Electric service has a separate lifecycle. Its replication slots and storage must be managed together according to Electric’s deployment guidance.

Writing to the Python objects or Node collection does not write back to Postgres. Send mutations through your application’s authorized write API, then let Electric synchronize the committed changes.

esc