Documentation sections

Work

Connect directly to your Workspace Database

A Workspace Database is a SQLite-compatible database served over HTTPS, one per name, scoped to your workspace. You can reach it from a workflow with the SDK, from an AI client through MCP, or directly — over raw HTTP or a libSQL-compatible client — which is what this page covers.

Last reviewed July 14, 2026

In the app Work → Databases

Before you start

  1. Create the database: solidactions database create <name>.
  2. Create an API key with the Databases group's Read ability (and Edit if you need write access). See API keys.
  3. Note your workspace ID — solidactions workspace list prints it under each workspace name. Every request below sends it in the X-Workspace-Id header, which must be the workspace UUID — a slug or name there is rejected with 422 validation_failed. If you only have the slug or name, callGET /api/v1/workspaces and read the id field of the matching entry.

Get credentials

Direct access does not use a CLI command — mint credentials from the REST control plane with theaccess operation. mode is required and must be exactly read orwrite. The optional duration_seconds must be an integer from 600 through86400 (10 minutes through 24 hours), inclusive. Omit it for the 600-second default.

This endpoint takes other operations, and some of them write.

POST /api/v1/databases is a single endpoint dispatched on operation. Besidesaccess it accepts list, create, delete,undelete, and dump — the same surface as the CLI'ssolidactions database subcommands. create and delete take effect immediately against your real workspace and count against your plan's database quota, so don't probe them to see what happens.

<your-workspace-id> is the workspace UUID, not the slug or name — sending a slug or name there returns 422 validation_failed instead of minting credentials.

curl -X POST "https://app.solidactions.com/api/v1/databases" \
  -H "Authorization: Bearer <your-api-key>" \
  -H "X-Workspace-Id: <your-workspace-id>" \
  -H "Content-Type: application/json" \
  -d '{"operation": "access", "name": "<database-name>", "mode": "read", "duration_seconds": 3600}'
{
  "url": "libsql://<your-database-host>",
  "token": "<jwt>",
  "mode": "read",
  "expires_at": "2026-08-11T20:35:01Z",
  "expires_in_seconds": 3600
}

Request the smallest duration that safely covers your expected batch. expires_at is a display estimate; use the additive expires_in_seconds with your own monotonic clock for renewal decisions. You can mint at most 20 access requests per rolling 60 seconds; going over that returns429 database_access_rate_limited with a Retry-After header.

Mint credentials at runtime — never store them.

Keep the URL, token, and expiry metadata in runtime memory only. Don't persist them, commit them, put them in browser storage, logs, or a config file, or send them to another service. Mark control-plane responsesCache-Control: no-store in any intermediary you operate.

Renew safely during a batch load

Record a monotonic timestamp when the access response arrives. Before each idempotent batch, mint again when elapsed time is at least expires_in_seconds - 60. Close the old client or session before replacing it, count renewals, and checkpoint completed work. Never retry arbitrary SQL: after an authentication failure, retry only the current batch when its boundary is known to be idempotent.

  1. Mint with the smallest safe duration_seconds and record response receipt on a monotonic clock.
  2. Before every batch, renew inside the fixed 60-second safety window and close the old client first.
  3. On database_credential_expired, mint again and resume only the current idempotent batch.
  4. On database_credential_revoked, close the client and try one fresh mint for the next batch.
  5. If that mint returns a typed fuse refusal, stop. Do not spin through the mint rate limit.

The reference below uses an injected monotonic clock. The site check imports this same module, so the rendered example and the executable 0, 9, 11, and 61-minute assertions cannot drift.

const RENEWAL_WINDOW_SECONDS = 60;

export function createDatabaseCredentialLease({ expiresInSeconds, monotonicNow }) {
  if (!Number.isInteger(expiresInSeconds) || expiresInSeconds < 600 || expiresInSeconds > 86400) {
    throw new RangeError('expiresInSeconds must be an integer from 600 through 86400');
  }
  if (typeof monotonicNow !== 'function') {
    throw new TypeError('monotonicNow must be a function');
  }

  const receivedAt = monotonicNow();
  const elapsedSeconds = () => Math.max(0, (monotonicNow() - receivedAt) / 1000);

  return Object.freeze({
    shouldRenew: () => elapsedSeconds() >= expiresInSeconds - RENEWAL_WINDOW_SECONDS,
    hasExpired: () => elapsedSeconds() >= expiresInSeconds,
  });
}

export function classifyDatabaseCredential401(error, lease) {
  const isGenericDriver401 = error?.name === 'LibsqlError'
    && error?.code === 'SERVER_ERROR'
    && error?.cause?.status === 401;
  if (!isGenericDriver401) {
    throw new TypeError('Expected a generic database driver 401');
  }

  return lease.hasExpired()
    ? 'database_credential_expired'
    : 'database_credential_revoked';
}

Tell expiry from revocation

A database driver may reduce natural expiry and credential rotation to the same generic 401. For that generic shape, classify database_credential_expired only when monotonic elapsed time is at least the fullexpires_in_seconds. Before that boundary, classifydatabase_credential_revoked. A raw HTTP 401 body containing token expired may be an early heuristic, but provider response text is not the stable contract.

This sanitized fixture contains the complete generic driver error observable by the classifier:

{
  "constructor": "LibsqlError",
  "name": "LibsqlError",
  "code": "SERVER_ERROR",
  "message": "SERVER_ERROR: Server returned HTTP status 401",
  "cause": {
    "constructor": "HttpServerError",
    "name": "HttpServerError",
    "status": 401,
    "message": "Server returned HTTP status 401"
  }
}

Query over HTTP

The access response gives you a libsql:// URL. For raw HTTP, swap the scheme tohttps:// and POST to /v2/pipeline. Drivers work the other way — they take thelibsql:// URL as-is; the scheme swap is only for raw HTTP calls like this one.

The database host is a different origin from the SolidActions app, it is unique per database, and you only learn it at runtime from the access response — so don't hard-code it, and if you run behind an egress allowlist, plan for a host you can't enumerate in advance. The host serves HTTPS only; a plainhttp:// request answers with a 308 redirect, which clients that don't follow redirects on POST will surface as an unhelpful failure.

curl -X POST "https://<your-database-host>/v2/pipeline" \
  -H "Authorization: Bearer $DB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "requests": [
      { "type": "execute",
        "stmt": {
          "sql": "SELECT id, body FROM notes WHERE id = ?",
          "args": [ { "type": "integer", "value": "1" } ]
        } },
      { "type": "close" }
    ]
  }'
{
  "baton": null,
  "base_url": null,
  "results": [
    {
      "type": "ok",
      "response": {
        "type": "execute",
        "result": {
          "cols": [
            { "name": "id", "decltype": "INTEGER" },
            { "name": "body", "decltype": "TEXT" }
          ],
          "rows": [
            [ { "type": "integer", "value": "1" },
              { "type": "text", "value": "hello from curl" } ]
          ],
          "affected_row_count": 0,
          "last_insert_rowid": null,
          "replication_index": null,
          "rows_read": 1,
          "rows_written": 0,
          "query_duration_ms": 0.113
        }
      }
    },
    { "type": "ok", "response": { "type": "close" } }
  ]
}

Every request array should end with {"type":"close"}. Values come back as typed cells, for example {"type":"integer","value":"1"} — integers arrive as strings so they survive 64-bit precision without rounding. Encode arguments the same way:

  • {"type":"null"}
  • {"type":"integer","value":"<string>"}
  • {"type":"float","value":<number>}
  • {"type":"text","value":"<string>"}

Booleans are sent as integer "1" / "0".

Blob columns return a fifth shape with a different key.

A BLOB column — including the F32_BLOB vectors in the example further down — comes back as {"type":"blob","base64":"<base64>"}. Note base64, notvalue. A cell parser that reads value unconditionally will break the first time it selects an embedding column, so branch on type before reading the payload.

Ending the request array with {"type":"close"} closes the server-side session immediately instead of leaving it to time out. It is optional — omitting it still returns a normal 200 — but include it unless you have a reason not to.

A failed statement doesn't fail the HTTP request — it arrives in-band as a 200 response with that result's type set to error:{"type":"error","error":{"message":"...","code":"..."}}. Check each result's type; don't assume success from the HTTP status alone.

A malformed request fails differently, and earlier: the endpoint rejects it with a400 and a body of {"error":"<parse error>"} — no results array at all. Encoding an integer argument as a JSON number instead of a string is the usual way to land here. Handle both: check the HTTP status first, then walk results.

Read-only vs. write

Read/write enforcement lives in the database itself, baked into the minted token — not something the caller has to honor. A mode: "read" token can run SELECT statements fine, but anINSERT, UPDATE, or DELETE comes back as an in-band error:

{
  "type": "error",
  "error": {
    "message": "Operation was blocked: SQL write operations are forbidden (current session doesn't have write permission)",
    "code": "BLOCKED"
  }
}

Separately, a workspace that exhausts its monthly write budget trips a write fuse: mode: "write"access requests then fail with 409 writes_exhausted. Reads keep working throughout.

A longer lifetime never bypasses a fuse.

A read, write, or storage trip rotates credentials across the tenant's entire database fleet, including read credentials and in-flight workflow credentials. That tenant-wide blast radius can interrupt current work even when a token has hours left. Every new mint reruns policy gates: after a storage trip, a fresh read mint can self-heal with read-only access, while a write mint remains terminal withstorage_exhausted. Treat reads_exhausted, writes_exhausted, andstorage_exhausted as policy decisions, not renewal signals.

What works

Execution is raw SQL passthrough — no query builder, no dialect filtering. Anything the database accepts works verbatim, including full-text search and vector search.

CREATE VIRTUAL TABLE notes_fts USING fts5(body);

SELECT body FROM notes_fts WHERE notes_fts MATCH 'neutral';
CREATE TABLE docs (id INTEGER PRIMARY KEY, embedding F32_BLOB(3));
INSERT INTO docs (id, embedding) VALUES (1, vector32('[0.1, 0.2, 0.3]'));

SELECT id, vector_distance_cos(embedding, vector32('[0.1, 0.2, 0.31]')) AS d
FROM docs ORDER BY d LIMIT 1;

Batch deletes and merges by composite key

A sink or merge job that deletes rows by a composite primary key naturally builds a WHERE clause with one OR-ed pair of equality checks per row:

DELETE FROM events
WHERE (tenant_id = ? AND event_id = ?)
   OR (tenant_id = ? AND event_id = ?)
   OR (tenant_id = ? AND event_id = ?);
-- one OR-ed pair per row in the batch

SQLite parses that chain into one nested expression tree and rejects it once the tree is deeper than the limit the backend enforces. That limit is 100, so a two-column key parses at most 98 rows — row 99 fails. Each additional key column deepens the innermost AND once, costing one further row of headroom in total rather than one per row, so a five-column key still reaches 95. The oversized statement fails withSQLite error: Expression tree is too large (maximum depth 100). Chunk well under 100 rows if you keep the OR shape.

Use row-value IN syntax instead. It expresses "any of these composite keys" as one flat list rather than nested trees, so it doesn't hit the depth limit. It is also exempt from the 500-termSQLITE_MAX_COMPOUND_SELECT ceiling: the same list written as a UNION ALL chain ofSELECTs stops there, while a VALUES list does not. That exemption is the non-obvious reason this form keeps scaling into the tens of thousands of rows:

DELETE FROM events
WHERE (tenant_id, event_id) IN (VALUES
  (?, ?),
  (?, ?),
  (?, ?)
);

The same rewrite applies to UPDATE and to SELECT ... WHERE (a, b) IN (...) lookups. Row-value IN still binds one placeholder per value, so one batch costs rows × key columns placeholders. The backend caps how many placeholders a single statement can carry (SQLite's own default forSQLITE_MAX_VARIABLE_NUMBER is 32766), and over the HTTP path arguments travel as JSON, where a request-size limit can bite before the placeholder ceiling does. Don't aim at the ceiling — chunk conservatively, in the 500–1,000 row range.

Compatible clients

The endpoint speaks the libSQL remote protocol (Hrana over HTTP). Any libSQL-compatible client works — point it at the libsql:// URL from the access response and pass the token as the client's auth token.

import { createClient } from '@libsql/client';

const client = createClient({
  url: process.env.DB_URL,       // the libsql:// URL from the access response
  authToken: process.env.DB_TOKEN,
});

const result = await client.execute('SELECT id, body FROM notes WHERE id = ?', [1]);
  • JavaScript / TypeScript@libsql/client on npm; this is exactly what the SolidActions CLI itself uses.
  • Pythonlibsql on PyPI.
  • Rustlibsql on crates.io.

Other ecosystems: search your package registry for libsql. If nothing turns up, the HTTP endpoint above is the portable fallback and needs nothing but an HTTP client.

Errors

CodeHTTP statusMeaning
unauthenticated401The API key is missing, malformed, or revoked.
not_found404The X-Workspace-Id is a well-formed UUID but matches no workspace. Check the header before you start suspecting the database name — this is the code a mistyped UUID produces, not workspace_forbidden. A slug or name instead of a UUID produces 422 validation_failed, not this code.
validation_failed422The request failed validation — for example, a missing or invalid mode, a duration_seconds outside 600..86400, a missing X-Workspace-Id header, or an X-Workspace-Id that isn't a UUID (a slug or name lands here).
token_missing_ability403The API key lacks the Databases ability the requested mode needs — Read for mode: "read", Edit for mode: "write".
forbidden403The caller is not authorized for this action.
workspace_forbidden403The API key does not belong to the given workspace.
database_not_found404No database with that name exists in the workspace.
database_not_ready409The database exists but hasn't finished provisioning yet.
reads_exhausted403The read fuse has tripped. Stop; credential renewal cannot restore database reads.
writes_exhausted409The write fuse has tripped. Read-mode access may still succeed.
storage_exhausted403The storage fuse has tripped. Write mints stop; after rotation, a fresh read mint may self-heal with read-only access.
database_access_rate_limited429More than 20 access requests in a rolling 60 seconds; retry after the Retry-After header.
upstream_unavailable502The database service was temporarily unreachable.

Next: query from inside a workflow with theTypeScript SDK, browseReference for CLI and SDK links, manage credentials inAPI keys, or import and export data with solidactions database dump,pull, and import.