Files
Access files across local storage and 60+ cloud providers through HTTP.
Every Hoody container runs hoody-sqlite, which exposes SQLite databases over HTTP. You execute SQL transactions, read and write key-value pairs, query values as they were at an earlier point in time, and run atomic operations, all as HTTP requests to the container’s SQLite URL. There is no database server to install and no connection string to configure.
The ?db= parameter accepts three forms:
?db=/data/app.db is used verbatim. Examples throughout this page use this form.?db=notes is shorthand resolved to /hoody/databases/notes.db../name shorthand: ?db=./notes is likewise resolved to /hoody/databases/notes.db.hoody-sqlite rejects an invalid or inaccessible path with a 400 error describing the problem.
Databases stored in /hoody/databases/ are shared through SQLite Drive and are safe for concurrent writes from several containers. You can query one over HTTP through hoody-sqlite or open the same file directly with a SQLite library such as Python sqlite3 or Node better-sqlite3, and mix both approaches in one application.
One service exposes both a SQL interface and a key-value interface over the same HTTP surface:
Full parameter, response, and example documentation lives in the API reference.
SQL Operations:
db (database path) and sql (Base64-encoded SELECT statement), both requiredpath, init_kv (auto-create KV table)Key-Value Store - Basic:
db, table, path (JSON path), at_timestamp (time-travel, Unix timestamp integer)ttl (auto-expiry), if_match (CAS), path (partial update), historyKey-Value Store - Batch:
Key-Value Store - Atomic:
delta (amount to add, default: 1)delta (amount to subtract, default: 1)index (position)Key-Value Store - Time-Travel:
limit (default 50, maximum 1000)op_number (integer, required; take it from the /history response)from, to (Unix timestamps)steps (number of changes to undo, default: 1)timestamp (Unix timestamp integer)to_timestamp (Unix timestamp integer, required), confirm=yes (required to apply), dry_run=true (preview without applying)Query History:
Web Interface:
System Monitoring:
Open the container’s SQLite URL in a browser:
https://{project}-{container}-sqlite-1.{server}.containers.hoody.comThe page provides:
Use it to explore a database, test a query, read table contents, or debug a data problem without leaving the browser. The interface is the same on a phone, a tablet, and a laptop.
For automation, post SQL over HTTP instead of connecting a client library:
# Execute SQL statements in a transactionhoody db exec-transaction -c $CONTAINER --db /data/app.db \ --transaction '[{"statement": "INSERT INTO users (name, email) VALUES (?, ?)", "values": ["Alice", "alice@example.com"]}, {"query": "SELECT * FROM users WHERE email = ?", "values": ["alice@example.com"]}]'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Pass the body (transaction array) as the first argument and the query// params (db, create_db_if_missing) as the second options object.const result = await containerClient.sqlite.database.executeTransaction( { transaction: [ { statement: 'INSERT INTO users (name, email) VALUES (?, ?)', values: ['Alice', 'alice@example.com'] }, { query: 'SELECT * FROM users WHERE email = ?', values: ['alice@example.com'] }, ], }, { db: '/data/app.db', create_db_if_missing: true },);console.log(result.data); // { results: [{ success: true, rowsUpdated: 1 }, { success: true, rowsUpdated: 0 }] }curl -X POST "https://$PROJECT-$CONTAINER-sqlite-1.$SERVER.containers.hoody.com/api/v1/sqlite/db?db=/data/app.db" \ -H "Content-Type: application/json" \ -d '{ "transaction": [ { "statement": "INSERT INTO users (name, email) VALUES (?, ?)", "values": ["Alice", "alice@example.com"] }, { "query": "SELECT * FROM users WHERE email = ?", "values": ["alice@example.com"] } ] }'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Runs the insert and the follow-up select as one transaction against /data/app.db.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-sqlite-1.SERVER.containers.hoody.com/api/v1/sqlite/db?db=/data/app.db&method=POST&json={"transaction":[{"statement":"INSERT%20INTO%20users%20(name,%20email)%20VALUES%20(?,%20?)","values":["Alice","alice@example.com"]},{"query":"SELECT%20*%20FROM%20users%20WHERE%20email%20=%20?","values":["alice@example.com"]}]}&response=transparent The response carries one result object per statement:
{ "results": [ {"success": true, "rowsUpdated": 1}, {"success": true, "rowsUpdated": 0} ]}Because the interface is HTTP, the same database is reachable from:
Read and write individual keys without writing SQL:
# Set a key-value pairhoody kv set "user:1" --db /data/app.db -c $CONTAINER --body '{"name": "Alice", "role": "editor"}'
# Get value by keyhoody kv get "user:1" --db /data/app.db -c $CONTAINER
# Delete a keyhoody kv delete "user:1" --db /data/app.db -c $CONTAINER --yes
# Atomic incrementhoody kv incr "views:homepage" --db /data/app.db -c $CONTAINERconst db = '/data/app.db';
// Set a key-value pair. Pass the key and raw value positionally, with `db` in the trailing options object.await containerClient.sqlite.kvStore.set('user:1', JSON.stringify({ name: 'Alice', role: 'editor' }), { db });
// Get value by key. The response body is the raw stored value, a JSON-encoded string.// Metadata such as TTL, expiry, and timestamps comes back in response headers, not in the body.const res = await containerClient.sqlite.kvStore.get('user:1', { db });const user = JSON.parse(res.data as string);console.log(user); // { name: "Alice", role: "editor" }
// Delete a keyawait containerClient.sqlite.kvStore.delete('user:1', { db });
// Atomic increment (thread-safe)await containerClient.sqlite.kvStore.incr('views:homepage', { db });# Set a key-value paircurl -X PUT "https://$PROJECT-$CONTAINER-sqlite-1.$SERVER.containers.hoody.com/api/v1/sqlite/kv/user:1?db=/data/app.db" \ -H "Content-Type: application/json" \ -d '{"name": "Alice", "role": "editor"}'
# Get value by keycurl "https://$PROJECT-$CONTAINER-sqlite-1.$SERVER.containers.hoody.com/api/v1/sqlite/kv/user:1?db=/data/app.db"
# Delete a keycurl -X DELETE "https://$PROJECT-$CONTAINER-sqlite-1.$SERVER.containers.hoody.com/api/v1/sqlite/kv/user:1?db=/data/app.db"
# Atomic incrementcurl -X POST "https://$PROJECT-$CONTAINER-sqlite-1.$SERVER.containers.hoody.com/api/v1/sqlite/kv/views:homepage/incr?db=/data/stats.db"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Sets, reads, deletes, and atomically increments individual keys in the KV store. The four links are independent requests, not a sequence.
# Set
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-sqlite-1.SERVER.containers.hoody.com/api/v1/sqlite/kv/user:1?db=/data/app.db&method=PUT&json={"name":"Alice","role":"editor"}&response=transparent
# Get
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-sqlite-1.SERVER.containers.hoody.com/api/v1/sqlite/kv/user:1?db=/data/app.db&method=GET&response=transparent
# Delete
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-sqlite-1.SERVER.containers.hoody.com/api/v1/sqlite/kv/user:1?db=/data/app.db&method=DELETE&response=transparent
# Increment
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-sqlite-1.SERVER.containers.hoody.com/api/v1/sqlite/kv/views:homepage/incr?db=/data/stats.db&method=POST&response=transparent Common uses:
Counters and array mutations run as one database operation, so shared state stays correct under concurrency:
When several clients call the endpoint at the same time, each increment applies on top of the last, so the counter runs 5 -> 6 -> 7 -> 8 rather than 5 -> 6 -> 6 -> 6.
The endpoints, by value type:
# Increment counterPOST /api/v1/sqlite/kv/{key}/incr?db=/data/app.db&delta=1
# Decrement inventoryPOST /api/v1/sqlite/kv/inventory:item1/decr?db=/data/app.db&delta=1
# Typical uses: views, likes, credits, inventory# Add item to shopping cart (the request body is the value to append)POST /api/v1/sqlite/kv/cart:user1/push?db=/data/store.db"product-123"
# Remove last itemPOST /api/v1/sqlite/kv/cart:user1/pop?db=/data/store.db
# Remove specific item (the request body is the value to match)POST /api/v1/sqlite/kv/cart:user1/remove?db=/data/store.db"product-123"Each of these applies as a single database operation, so your client does not need its own locking.
Up to 100 keys move in one atomic request:
// Set multiple keys atomicallyawait fetch('.../kv/batch/set?db=/data/app.db', { method: 'POST', body: JSON.stringify({ items: [ { key: 'user:1', value: { name: 'Alice' } }, { key: 'user:2', value: { name: 'Bob' } }, { key: 'user:3', value: { name: 'Carol' } } ] })});
// Get multiple keys in one requestawait fetch('.../kv/batch/get?db=/data/app.db', { method: 'POST', body: JSON.stringify({ keys: ['user:1', 'user:2', 'user:3'] })});One batch replaces up to 100 round trips. Every operation in it succeeds, or the whole batch fails.
Every write is recorded in the key’s history:
# View a key's change history, newest first (returns op_number for each entry)GET /api/v1/sqlite/kv/config:timeout/history?db=/data/app.db
# Get value at a specific operation number (op_number from history)GET /api/v1/sqlite/kv/config:timeout/snapshot?op_number=42&db=/data/app.db
# Undo last changePOST /api/v1/sqlite/kv/config:timeout/rollback?db=/data/app.db
# Restore entire database to 2 hours ago (preview with &dry_run=true; add &confirm=yes to apply)timestamp=$(date -d '2 hours ago' +%s)POST /api/v1/sqlite/kv/rollback?to_timestamp=$timestamp&db=/data/app.dbCommon uses:
Encode a SELECT statement into a URL and share it:
// 1. Define queryconst sql = "SELECT * FROM users WHERE status = 'active' ORDER BY created_at DESC";
// 2. Encode as Base64const encoded = btoa(sql);
// 3. Create shareable URLconst queryUrl = `/api/v1/sqlite/query?db=/data/app.db&sql=${encoded}`;
// Anyone with this URL can read live data// The endpoint is read-only, so a shared URL cannot writeTypical uses:
Database Server (install) → Connection Pool (configure) → Client Library (install) → Query (finally)A conventional setup involves:
HTTP Request → SQLite File → Response (immediately)The HTTP interface removes those steps:
Because the database speaks HTTP, all of these work without a driver.
From a phone browser:
// From mobile browserconst users = await fetch( 'https://{project}-{container}-sqlite-1.{server}.containers.hoody.com/api/v1/sqlite/db?db=/data/app.db', { method: 'POST', body: JSON.stringify({ transaction: [{ query: 'SELECT * FROM users LIMIT 10' }] }) }).then(r => r.json());From an AI agent:
// AI makes standard HTTP requestawait fetch(sqliteUrl + '/api/v1/sqlite/db?db=/data/app.db', { method: 'POST', body: JSON.stringify({ transaction: [{ query: 'SELECT COUNT(*) FROM orders WHERE status = "pending"' }] })});// An ordinary fetch call, with no driver or connection string involvedEmbedded in a page:
<iframe src="https://{project}-{container}-sqlite-1.{server}.containers.hoody.com/api/v1/sqlite/query?db=/data/stats.db&sql=U0VMRUNUIC4uLg==" />Store configuration under one key and read it back at startup:
// Set configurationawait fetch('.../kv/config:api?db=/data/app.db', { method: 'PUT', body: JSON.stringify({ timeout: 30, retries: 3, endpoint: 'https://api.example.com' })});
// Get configurationconst config = await fetch('.../kv/config:api?db=/data/app.db') .then(r => r.json());
// Use in appconst response = await fetch(config.endpoint, { timeout: config.timeout });Track a metric written by many clients at once:
// Each page view increments atomicallyawait fetch('.../kv/views:homepage/incr?db=/data/stats.db', { method: 'POST'});
// Get current countconst count = await fetch('.../kv/views:homepage?db=/data/stats.db') .then(r => r.json());
console.log(`Homepage views: ${count}`);A thousand simultaneous requests increment the counter exactly one thousand times.
Store sessions with a TTL so they expire on their own:
// Create session with 1-hour TTLawait fetch('.../kv/session:abc123?db=/data/sessions.db&ttl=3600', { method: 'PUT', body: JSON.stringify({ user_id: 'user-456', ip: '203.0.113.50', created_at: Date.now() })});
// The key is removed an hour after it is written// You do not need a cleanup jobPush, remove, and read cart items without read-modify-write races:
// Add item to cartawait fetch('.../kv/cart:user1/push?db=/data/store.db', { method: 'POST', body: JSON.stringify({ product_id: 'prod-xyz', quantity: 2, price: 29.99 })});
// Remove itemawait fetch('.../kv/cart:user1/remove?db=/data/store.db', { method: 'POST', body: JSON.stringify({ product_id: 'prod-xyz' })});
// Get entire cartconst cart = await fetch('.../kv/cart:user1?db=/data/store.db') .then(r => r.json());One .db file serves both interfaces:
// SQL for complex queriesconst analytics = await fetch('.../api/v1/sqlite/db?db=/data/app.db', { method: 'POST', body: JSON.stringify({ transaction: [{ query: 'SELECT product_id, COUNT(*) as purchases FROM orders GROUP BY product_id ORDER BY purchases DESC LIMIT 10' }] })}).then(r => r.json());
// KV for simple configconst config = await fetch('.../kv/config:featured?db=/data/app.db') .then(r => r.json());
// Combine resultsconst featured = analytics.results[0].resultSet.filter(item => config.featured_products.includes(item.product_id));Read the key’s history to find the operation where the value was still correct, then undo the last two changes:
# Change history, newest first, with an op_number for each entryhoody kv history "config:api" --db /data/app.db -c $CONTAINER
# Undo the last 2 changes to the keyhoody kv rollback "config:api" --db /data/app.db --steps 2 -c $CONTAINERimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Change history, newest first, with an op_number for each entryconst history = await containerClient.sqlite.kvStore.getHistory('config:api', { db: '/data/app.db' });
// Undo the last 2 changes to the keyawait containerClient.sqlite.kvStore.rollback('config:api', { db: '/data/app.db', steps: 2 });# Change history, newest first, with an op_number for each entrycurl "https://$PROJECT-$CONTAINER-sqlite-1.$SERVER.containers.hoody.com/api/v1/sqlite/kv/config:api/history?db=/data/app.db"
# Undo the last 2 changes to the keycurl -X POST "https://$PROJECT-$CONTAINER-sqlite-1.$SERVER.containers.hoody.com/api/v1/sqlite/kv/config:api/rollback?db=/data/app.db&steps=2"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Reads the key’s change history, newest first, then undoes the last two changes to it.
# History
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-sqlite-1.SERVER.containers.hoody.com/api/v1/sqlite/kv/config:api/history?db=/data/app.db&method=GET&response=transparent
# Rollback
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-sqlite-1.SERVER.containers.hoody.com/api/v1/sqlite/kv/config:api/rollback?db=/data/app.db%26steps=2&method=POST&response=transparent Or restore the whole table to an hour ago:
timestamp=$(date -d '1 hour ago' +%s)
# Preview the changes firsthoody kv rollback-table --db /data/app.db --to-timestamp $timestamp --dry-run -c $CONTAINER
# Applyhoody kv rollback-table --db /data/app.db --to-timestamp $timestamp --confirm yes -c $CONTAINERconst timestamp = Math.floor(Date.now() / 1000) - 3600;
// Preview the changes firstawait containerClient.sqlite.kvStore.rollbackTable({}, { db: '/data/app.db', to_timestamp: timestamp, dry_run: true });
// Applyawait containerClient.sqlite.kvStore.rollbackTable({}, { db: '/data/app.db', to_timestamp: timestamp, confirm: 'yes' });timestamp=$(date -d '1 hour ago' +%s)
# Preview the changes firstcurl -X POST "https://$PROJECT-$CONTAINER-sqlite-1.$SERVER.containers.hoody.com/api/v1/sqlite/kv/rollback?db=/data/app.db&to_timestamp=$timestamp&dry_run=true"
# Applycurl -X POST "https://$PROJECT-$CONTAINER-sqlite-1.$SERVER.containers.hoody.com/api/v1/sqlite/kv/rollback?db=/data/app.db&to_timestamp=$timestamp&confirm=yes"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Rolls the whole KV table back to a past Unix timestamp: Preview runs a dry run, Apply commits the same rollback with confirm=yes. Run Preview first since the rollback is destructive.
# Preview
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-sqlite-1.SERVER.containers.hoody.com/api/v1/sqlite/kv/rollback?db=/data/app.db%26to_timestamp=TIMESTAMP%26dry_run=true&method=POST&response=transparent
# Apply
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-sqlite-1.SERVER.containers.hoody.com/api/v1/sqlite/kv/rollback?db=/data/app.db%26to_timestamp=TIMESTAMP%26confirm=yes&method=POST&response=transparent Keep conversation state across agent runs:
// AI stores contextawait fetch('.../kv/agent:conversation:123?db=/data/agent.db', { method: 'PUT', body: JSON.stringify({ user_request: 'Build a todo app', plan: ['Create database', 'Build API', 'Create frontend'], progress: { completed: 0, total: 3 } })});
// AI retrieves context laterconst memory = await fetch('.../kv/agent:conversation:123?db=/data/agent.db') .then(r => r.json());
// AI continues from where it left offWrite flags as keys and read them at runtime:
// Set feature flagsawait fetch('.../kv/batch/set?db=/data/app.db', { method: 'POST', body: JSON.stringify({ items: [ { key: 'feature:new-ui', value: true }, { key: 'feature:beta-api', value: false }, { key: 'feature:dark-mode', value: true } ] })});
// Check feature in appconst newUiEnabled = await fetch('.../kv/feature:new-ui?db=/data/app.db') .then(r => r.json());
if (newUiEnabled) { // Show new UI}Track API calls per user:
// Increment user's API call countawait fetch(`.../kv/api-calls:user-${userId}/incr?db=/data/limits.db`, { method: 'POST'});
// Get current countconst calls = await fetch(`.../kv/api-calls:user-${userId}?db=/data/limits.db`) .then(r => r.json());
if (calls > 1000) { return { error: 'Rate limit exceeded' };}Atomic increment prevents double counting when requests overlap, so the count stays right under high concurrency.
Cache expensive API responses:
const cacheKey = `cache:api:${endpoint}`;
// Check cacheconst cached = await fetch(`.../kv/${cacheKey}?db=/data/cache.db`) .then(r => r.json()) .catch(() => null);
if (cached) { return cached;}
// Fetch from APIconst data = await fetchFromAPI(endpoint);
// Store with 10-minute TTLawait fetch(`.../kv/${cacheKey}?db=/data/cache.db&ttl=600`, { method: 'PUT', body: JSON.stringify(data)});
return data;Track every data change:
// Make changeawait fetch('.../kv/user:1:email?db=/data/users.db', { method: 'PUT', body: JSON.stringify('new-email@example.com')});
// Later: Generate compliance reportconst history = await fetch('.../kv/user:1:email/history?db=/data/users.db') .then(r => r.json());
// Each entry shows what changed, when, and the values before and after// The history is recorded without extra instrumentationA single configuration value does not need a SQL query:
// Heavier than needed: SQL for one config valueconst sql = 'SELECT value FROM config WHERE key = "timeout"';
// Simpler: the same value from the KV storeconst timeout = await fetch('.../kv/config:timeout?db=/data/app.db') .then(r => r.json());Use SQL for JOINs, complex queries, and relationships. Use the KV store for plain key-value pairs, configuration, and caching.
One request instead of one request per key:
// Slower: 100 individual requestsfor (const user of users) { await fetch(`.../kv/user:${user.id}`, { method: 'PUT', body: JSON.stringify(user) });}
// Faster: 1 batch requestawait fetch('.../kv/batch/set?db=/data/app.db', { method: 'POST', body: JSON.stringify({ items: users.map(user => ({ key: `user:${user.id}`, value: user })) })});Increment on the server rather than in the client:
// Correct: the read and the write happen in one operationPOST /api/v1/sqlite/kv/counter/incr
// Wrong: the read and the write are separateconst current = await GET /api/v1/sqlite/kv/counter;await PUT /api/v1/sqlite/kv/counter (current + 1);// Two clients doing this at once lose one of the incrementsGive sessions, cached responses, and tokens an expiry:
// Session expires in 24 hoursawait fetch('.../kv/session:xyz?db=/data/sessions.db&ttl=86400', { method: 'PUT', body: JSON.stringify({ user_id: '123' })});
// The key is deleted at expiry, with no cleanup jobBefore a bulk update, note the current Unix timestamp. If the result is wrong, the KV store’s history lets you undo it: see Recovery from a bad change for the dry-run-then-confirm flow.
Yes. The SQL operations endpoint runs standard SQL. Instead of connecting with mysql -u user -p or psql, you send an HTTP POST. The SQL is the same; only the transport differs.
The concepts overlap: key-value access, atomic operations, TTL. The implementations do not. Redis is in-memory with persistence options; the hoody-sqlite KV store is SQLite-backed and disk-first. Redis has pub/sub; hoody-sqlite has history and time-travel. Both are reachable over HTTP inside a Hoody container.
Yes. Store the database in /hoody/databases/ and SQLite Drive shares it. That directory is shared across every container in your project, with locking and concurrency handled for you, so several containers can read and write the same database at the same time.
The other pattern: one container owns a database in /data/ and exposes it through its hoody-sqlite HTTP endpoints, and other containers query over HTTP. Both work.
Each change stores the key, the old value, the new value, and a timestamp. In most cases that adds 5-10% to the size of the data. For ephemeral data such as session tracking, turn it off with ?history=false.
Yes. An agent runs SQL or KV operations with the same HTTP POST requests as any other client, and needs no database driver. Querying, reading results, and acting on them all happen over HTTP.
SQLite’s format allows databases up to 281 TB. In practice, keep a database under 100GB for good performance. Beyond that, split the data across several databases or move to a container with more storage.
Each one runs at the database level in a single step. incr performs its read, modify, and write without another operation interrupting between the read and the write. hoody-sqlite serializes writes to each database behind a per-database lock, and each operation runs in a single transaction.
Yes. A SQLite database is a single file. Copy the .db through the hoody-files service, a container snapshot, or ordinary file operations. To back up a database that is in use, use SQLite’s VACUUM INTO or its backup API.
No. Pass ?init_kv=true when you create the database and the KV table and its indexes are created for you. If you skip that, the first KV operation creates the table.
Problem: “Database is locked” errors during writes
Cause: SQLite allows one writer at a time per database
Preferred fix: move the database to SQLite Drive, the shared database layer that removes locking issues for applications spread over several containers.
Store the database in /hoody/databases/ for shared access:
# Instead of: /data/app.db (single-container, can lock under concurrency)# Use: /hoody/databases/app.db (multi-container safe, no locking)What that path gives you:
See: SQLite Drive → for setup and cross-container database access patterns.
If the database stays in /data/:
Use batch operations to reduce requests:
# Instead of 100 individual SETs# Use 1 batch SETPOST /api/v1/sqlite/kv/batch/setRetry with exponential backoff:
async function retryWrite(url, body, maxRetries = 3) { for (let i = 0; i < maxRetries; i++) { try { return await fetch(url, { method: 'PUT', body }); } catch (e) { if (i < maxRetries - 1) { await new Promise(r => setTimeout(r, 100 * Math.pow(2, i))); } } }}Write-ahead logging is not something you need to turn on: every database hoody-sqlite opens is already in WAL mode, with a 30-second busy timeout.
Problem: GET returns null for a key you expect to exist
Check:
Verify key spelling (case-sensitive):
# Wrong: user:1# Correct: User:1 (if that's what you SET)Check key hasn’t expired:
# If TTL was set, key auto-deletes on expiryList all keys to verify:
-- Via SQLSELECT key FROM kv_store LIMIT 100;Problem: the rollback call completes but the data is unchanged
Possible causes:
Missing confirm=yes on table rollback:
# Without confirm=yes, POST /kv/rollback does not apply the rollback# Preview with &dry_run=true, then add &confirm=yes to actually apply:POST /api/v1/sqlite/kv/rollback?to_timestamp=...&db=...&confirm=yesHistory disabled when data was written:
# If original SET had ?history=false# No history = can't rollbackTimestamp too far back:
# Rollback only works if history exists for that time# Check history first:GET /api/v1/sqlite/kv/{key}/historyWrong database file:
# Verify ?db= parameter points to correct fileProblem: some keys in a batch succeed while others fail
Cause: batch operations are atomic, so every key succeeds or the whole batch fails. Partial success means the writes went out as separate requests rather than one batch.
Verify:
// Correct: one atomic batchPOST /api/v1/sqlite/kv/batch/set{ items: [ { key: 'k1', value: v1 }, { key: 'k2', value: v2 } ]}
// Wrong: two separate requestsPOST /api/v1/sqlite/kv/k1 (value: v1)POST /api/v1/sqlite/kv/k2 (value: v2)Check SQL syntax and parameters:
// Use parameterized queries{ query: 'SELECT * FROM users WHERE id = ?', values: [userId] // Not: `WHERE id = ${userId}` (SQL injection risk)}Verify database state:
# List tablesSELECT name FROM sqlite_master WHERE type='table';
# Check schemaPRAGMA table_info(users);Other data services:
Files
Access files across local storage and 60+ cloud providers through HTTP.
Exec
Serve a script as an HTTP endpoint, with no framework or deploy step.
SQLite reference pages: