Rapid Internal Tools
Section titled “Rapid Internal Tools”In most companies, critical business processes run on spreadsheets, Slack messages, and manual copy-paste between systems. The admin dashboard that would take two months to build properly never gets built. A webhook processor that should exist gets replaced by someone checking email. Reports that could be automated are generated by hand every Friday.
These tools stay unbuilt because the overhead is out of proportion to the result: a server to provision, a database to configure, authentication to set up, a deployment pipeline to write, SSL certificates to manage, all for a tool three people will use.
On Hoody, an internal tool is a file. You write a function and hoody-exec serves it as an HTTP endpoint. You query the database through HTTP and share the URL with your team. The file is the tool, with no infrastructure, deploy step, or maintenance behind it.
What Hoody replaces
Section titled “What Hoody replaces”| Traditional internal tool | Hoody internal tool |
|---|---|
| Provision a server | Already have one |
| Install a web framework | Write a function in a file |
| Set up a database | hoody-sqlite is built in and starts on the first request |
| Configure authentication | Proxy permissions |
| Write deployment scripts | Files are live instantly |
| Manage SSL certificates | Handled by the proxy |
| Monitor uptime | hoody-daemon auto-restarts |
| Schedule tasks | hoody-cron is already running |
The time between needing a tool and having it drops from weeks to minutes.
Build an admin dashboard
Section titled “Build an admin dashboard”The first build reads user data from SQLite and returns JSON. The frontend can be any HTML page, a React app, or a curl command.
Step 1: Create the data
Section titled “Step 1: Create the data”# Create the users table with sample data (-c targets the container; --db is required;# --create-db-if-missing creates app.db on first use)hoody db transactions run -c $CONTAINER_ID --db /hoody/databases/app.db --create-db-if-missing \ --transaction '[{"statement": "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL, name TEXT NOT NULL, role TEXT DEFAULT '\''user'\'', status TEXT DEFAULT '\''active'\'', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_login DATETIME)"}, {"statement": "CREATE TABLE IF NOT EXISTS webhook_events (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, event_type TEXT NOT NULL, payload TEXT NOT NULL, received_at DATETIME NOT NULL)"}]'
# Insert sample datahoody db transactions run -c $CONTAINER_ID --db /hoody/databases/app.db \ --transaction '[{"statement": "INSERT INTO users (email, name, role, status, last_login) VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)", "values": ["alice@company.com", "Alice Chen", "admin", "active", "2026-03-03", "bob@company.com", "Bob Martinez", "user", "active", "2026-03-04", "carol@company.com", "Carol Kim", "user", "suspended", "2026-02-15"]}]'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,});
// Seed the table by POSTing a transaction to the SQLite service URLconst sqliteUrl = `https://${PROJECT_ID}-${CONTAINER_ID}-sqlite-1.${SERVER}.containers.hoody.com`;await fetch(`${sqliteUrl}/api/v1/sqlite/db?db=/hoody/databases/app.db&create_db_if_missing=true`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ transaction: [ { statement: `CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL, name TEXT NOT NULL, role TEXT DEFAULT 'user', status TEXT DEFAULT 'active', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_login DATETIME )` }, { statement: "INSERT INTO users (email, name, role, status, last_login) VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)", values: ["alice@company.com", "Alice Chen", "admin", "active", "2026-03-03", "bob@company.com", "Bob Martinez", "user", "active", "2026-03-04", "carol@company.com", "Carol Kim", "user", "suspended", "2026-02-15"] }, { statement: "CREATE TABLE IF NOT EXISTS webhook_events (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, event_type TEXT NOT NULL, payload TEXT NOT NULL, received_at DATETIME NOT NULL)" } ], })});curl -X POST "https://$PROJECT_ID-$CONTAINER_ID-sqlite-1.$SERVER.containers.hoody.com/api/v1/sqlite/db?db=/hoody/databases/app.db&create_db_if_missing=true" \ -H "Content-Type: application/json" \ -d '{ "transaction": [{"statement": "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL, name TEXT NOT NULL, role TEXT DEFAULT '\''user'\'', status TEXT DEFAULT '\''active'\'', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_login DATETIME)"}, {"statement": "INSERT INTO users (email, name, role, status, last_login) VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)", "values": ["alice@company.com", "Alice Chen", "admin", "active", "2026-03-03", "bob@company.com", "Bob Martinez", "user", "active", "2026-03-04", "carol@company.com", "Carol Kim", "user", "suspended", "2026-02-15"]}, {"statement": "CREATE TABLE IF NOT EXISTS webhook_events (id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, event_type TEXT NOT NULL, payload TEXT NOT NULL, received_at DATETIME NOT NULL)"}] }'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
Creates the users table in app.db through hoody-sqlite. The dashboard and report scripts below both query this table, so run this first.
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=/hoody/databases/app.db%26create_db_if_missing=true&method=POST&json={"transaction":[{"statement":"CREATE%20TABLE%20IF%20NOT%20EXISTS%20users%20(id%20INTEGER%20PRIMARY%20KEY%20AUTOINCREMENT,%20email%20TEXT%20NOT%20NULL,%20name%20TEXT%20NOT%20NULL,%20role%20TEXT%20DEFAULT%20'user',%20status%20TEXT%20DEFAULT%20'active',%20created_at%20DATETIME%20DEFAULT%20CURRENT_TIMESTAMP,%20last_login%20DATETIME)"},{"statement":"INSERT%20INTO%20users%20(email,%20name,%20role,%20status,%20last_login)%20VALUES%20(?,%20?,%20?,%20?,%20?),%20(?,%20?,%20?,%20?,%20?),%20(?,%20?,%20?,%20?,%20?)","values":["alice@company.com","Alice%20Chen","admin","active","2026-03-03","bob@company.com","Bob%20Martinez","user","active","2026-03-04","carol@company.com","Carol%20Kim","user","suspended","2026-02-15"]},{"statement":"CREATE%20TABLE%20IF%20NOT%20EXISTS%20webhook_events%20(id%20INTEGER%20PRIMARY%20KEY%20AUTOINCREMENT,%20source%20TEXT%20NOT%20NULL,%20event_type%20TEXT%20NOT%20NULL,%20payload%20TEXT%20NOT%20NULL,%20received_at%20DATETIME%20NOT%20NULL)"}]}&response=transparent Step 2: Write the dashboard API
Section titled “Step 2: Write the dashboard API”Write the dashboard backend as a hoody-exec script:
# The script is written verbatim, so pass real newlines, not a "\n" escapehoody exec scripts write -c $CONTAINER_ID \ --path "admin/dashboard.ts" \ --content \'// @mode serverless// @cors reflective// @timeout 5000
const SQLITE = "https://PROJECT-CONTAINER-sqlite-1.SERVER.containers.hoody.com";
const stats = await fetch(SQLITE + "/api/v1/sqlite/db?db=/hoody/databases/app.db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ transaction: [{ query: "SELECT COUNT(*) as total_users FROM users" }] })}).then(r => r.json());
return { statistics: stats, generated_at: new Date().toISOString() };'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,});
const sqliteUrl = `https://${PROJECT_ID}-${CONTAINER_ID}-sqlite-1.${SERVER}.containers.hoody.com`;
await containerClient.exec.scripts.write({ path: 'admin/dashboard.ts', content: `// @mode serverless// @cors reflective// @timeout 5000
const SQLITE = "${sqliteUrl}";
const stats = await fetch(SQLITE + "/api/v1/sqlite/db?db=/hoody/databases/app.db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ transaction: [{ query: \`SELECT COUNT(*) as total_users, SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active_users, SUM(CASE WHEN status = 'suspended' THEN 1 ELSE 0 END) as suspended_users, SUM(CASE WHEN role = 'admin' THEN 1 ELSE 0 END) as admin_count, SUM(CASE WHEN last_login > datetime('now', '-7 days') THEN 1 ELSE 0 END) as active_this_week FROM users\` }] })}).then(r => r.json());
const recent = await fetch(SQLITE + "/api/v1/sqlite/db?db=/hoody/databases/app.db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ transaction: [{ query: "SELECT name, email, role, status, last_login FROM users ORDER BY last_login DESC LIMIT 10" }] })}).then(r => r.json());
return { statistics: stats.results?.[0]?.resultSet?.[0] ?? stats.results?.[0], recent_logins: recent.results?.[0]?.resultSet ?? recent.results, generated_at: new Date().toISOString()}; `, createDirs: true,});curl -X POST "https://$PROJECT_ID-$CONTAINER_ID-exec-1.$SERVER.containers.hoody.com/api/v1/exec/scripts/write" \ -H "Content-Type: application/json" \ -d '{ "path": "admin/dashboard.ts", "content": "// @mode serverless\n// @cors reflective\n// @timeout 5000\n\nconst SQLITE = \"https://PROJECT-CONTAINER-sqlite-1.SERVER.containers.hoody.com\";\n\nconst stats = await fetch(SQLITE + \"/api/v1/sqlite/db?db=/hoody/databases/app.db\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ transaction: [{ query: \"SELECT COUNT(*) as total_users FROM users\" }] })\n}).then(r => r.json());\n\nreturn { statistics: stats, generated_at: new Date().toISOString() };", "createDirs": true }'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
Writes the dashboard script to admin/dashboard.ts via hoody-exec; it is live at /admin/dashboard as soon as the write returns.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-exec-1.SERVER.containers.hoody.com/api/v1/exec/scripts/write&method=POST&json={"path":"admin/dashboard.ts","content":"//%20@mode%20serverless\n//%20@cors%20reflective\n//%20@timeout%205000\n\nconst%20SQLITE%20=%20\"https://PROJECT-CONTAINER-sqlite-1.SERVER.containers.hoody.com\";\n\nconst%20stats%20=%20await%20fetch(SQLITE%20%2B%20\"/api/v1/sqlite/db?db=/hoody/databases/app.db\",%20{\n%20%20method:%20\"POST\",\n%20%20headers:%20{%20\"Content-Type\":%20\"application/json\"%20},\n%20%20body:%20JSON.stringify({%20transaction:%20[{%20query:%20\"SELECT%20COUNT(*)%20as%20total_users%20FROM%20users\"%20}]%20})\n}).then(r%20=>%20r.json());\n\nreturn%20{%20statistics:%20stats,%20generated_at:%20new%20Date().toISOString()%20};","createDirs":true}&response=transparent Step 3: Call the endpoint
Section titled “Step 3: Call the endpoint”Your dashboard API is now live:
curl "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/admin/dashboard"SDK-tab response (the CLI, HTTP, and URL variants return the single SQLite count response under statistics and omit recent_logins):
{ "statistics": { "total_users": 3, "active_users": 2, "suspended_users": 1, "admin_count": 1, "active_this_week": 2 }, "recent_logins": [ { "name": "Bob Martinez", "email": "bob@company.com", "role": "user", "status": "active", "last_login": "2026-03-04 00:00:00" }, { "name": "Alice Chen", "email": "alice@company.com", "role": "admin", "status": "active", "last_login": "2026-03-03 00:00:00" }, { "name": "Carol Kim", "email": "carol@company.com", "role": "user", "status": "suspended", "last_login": "2026-02-15 00:00:00" } ], "generated_at": "2026-03-04T12:00:00.000Z"}The working dashboard is one script file behind one URL; nothing else was provisioned.
Build a webhook processor
Section titled “Build a webhook processor”The second build receives webhooks from external services, stores them in SQLite, and sends notifications.
Write the webhook script
Section titled “Write the webhook script”# The script is written verbatim, so pass real newlines, not a "\n" escapehoody exec scripts write -c $CONTAINER_ID \ --path "webhooks/stripe.ts" \ --content \'// @mode serverless// @cors *// @timeout 10000
const SQLITE = "https://PROJECT-CONTAINER-sqlite-1.SERVER.containers.hoody.com";const NOTIFY = "https://PROJECT-CONTAINER-n-1.SERVER.containers.hoody.com";
const saved = await fetch(SQLITE + "/api/v1/sqlite/db?db=/hoody/databases/app.db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ transaction: [{ statement: "INSERT INTO webhook_events (source, event_type, payload, received_at) VALUES (?, ?, ?, ?)", values: ["stripe", req.body.type, JSON.stringify(req.body), new Date().toISOString()] }] })});
if (!saved.ok) { // Not stored: answer an error so the sender retries. res.statusCode = 502; return { received: false };}
return { received: true, event: req.body.type };'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,});
const sqliteUrl = `https://${PROJECT_ID}-${CONTAINER_ID}-sqlite-1.${SERVER}.containers.hoody.com`;const notificationUrl = `https://${PROJECT_ID}-${CONTAINER_ID}-n-1.${SERVER}.containers.hoody.com`;
await containerClient.exec.scripts.write({ path: 'webhooks/stripe.ts', content: `// @mode serverless// @cors *// @timeout 10000
const SQLITE = "${sqliteUrl}";const NOTIFY = "${notificationUrl}";
const saved = await fetch(SQLITE + "/api/v1/sqlite/db?db=/hoody/databases/app.db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ transaction: [{ statement: "INSERT INTO webhook_events (source, event_type, payload, received_at) VALUES (?, ?, ?, ?)", values: ["stripe", req.body.type, JSON.stringify(req.body), new Date().toISOString()] }] })});
if (!saved.ok) { // Not stored: answer an error so the sender retries. res.statusCode = 502; return { received: false };}
if (req.body.type === "payment_intent.succeeded") { const amount = req.body.data.object.amount / 100; await fetch(NOTIFY + "/api/v1/notifications/notify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ summary: "Payment Received", body: amount + " " + req.body.data.object.currency.toUpperCase() + " payment successful", display: "1", urgency: "normal" }) });}
return { received: true, event: req.body.type }; `, createDirs: true,});curl -X POST "https://$PROJECT_ID-$CONTAINER_ID-exec-1.$SERVER.containers.hoody.com/api/v1/exec/scripts/write" \ -H "Content-Type: application/json" \ -d '{ "path": "webhooks/stripe.ts", "content": "// @mode serverless\n// @cors *\n// @timeout 10000\n\nconst SQLITE = \"https://PROJECT-CONTAINER-sqlite-1.SERVER.containers.hoody.com\";\n\nconst saved = await fetch(SQLITE + \"/api/v1/sqlite/db?db=/hoody/databases/app.db\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n transaction: [{\n statement: \"INSERT INTO webhook_events (source, event_type, payload, received_at) VALUES (?, ?, ?, ?)\",\n values: [\"stripe\", req.body.type, JSON.stringify(req.body), new Date().toISOString()]\n }]\n })\n});\n\nif (!saved.ok) {\n // Not stored: answer an error so the sender retries.\n res.statusCode = 502;\n return { received: false };\n}\n\nreturn { received: true, event: req.body.type };", "createDirs": true }'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
Writes the webhook script to webhooks/stripe.ts via hoody-exec. This body only stores the event; the payment-notification branch shown in the SDK tab is not part of this request.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-exec-1.SERVER.containers.hoody.com/api/v1/exec/scripts/write&method=POST&json={"path":"webhooks/stripe.ts","content":"//%20@mode%20serverless\n//%20@cors%20*\n//%20@timeout%2010000\n\nconst%20SQLITE%20=%20\"https://PROJECT-CONTAINER-sqlite-1.SERVER.containers.hoody.com\";\n\nconst%20saved%20=%20await%20fetch(SQLITE%20%2B%20\"/api/v1/sqlite/db?db=/hoody/databases/app.db\",%20{\n%20%20method:%20\"POST\",\n%20%20headers:%20{%20\"Content-Type\":%20\"application/json\"%20},\n%20%20body:%20JSON.stringify({\n%20%20%20%20transaction:%20[{\n%20%20%20%20%20%20statement:%20\"INSERT%20INTO%20webhook_events%20(source,%20event_type,%20payload,%20received_at)%20VALUES%20(?,%20?,%20?,%20?)\",\n%20%20%20%20%20%20values:%20[\"stripe\",%20req.body.type,%20JSON.stringify(req.body),%20new%20Date().toISOString()]\n%20%20%20%20}]\n%20%20})\n});\n\nif%20(!saved.ok)%20{\n%20%20//%20Not%20stored:%20answer%20an%20error%20so%20the%20sender%20retries.\n%20%20res.statusCode%20=%20502;\n%20%20return%20{%20received:%20false%20};\n}\n\nreturn%20{%20received:%20true,%20event:%20req.body.type%20};","createDirs":true}&response=transparent Point Stripe’s webhook URL at:
https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/webhooks/stripeA webhook is acknowledged only once SQLite has stored it; if the write fails, the script answers 502 so the sender retries it. In the SDK-tab version, successful payments trigger a desktop notification on display 1; the CLI, HTTP, and URL versions only store and acknowledge the event. Each request is an isolated execution that writes straight to SQLite, so the database transaction is the source of truth and there is no shared in-memory state to corrupt.
Build a report generator
Section titled “Build a report generator”The third build generates CSV reports from database queries, serves them through hoody-exec, and—in the SDK variant—archives copies through hoody-files.
Write the report script
Section titled “Write the report script”SQLITE="https://$PROJECT_ID-$CONTAINER_ID-sqlite-1.$SERVER.containers.hoody.com"FILES="https://$PROJECT_ID-$CONTAINER_ID-files-1.$SERVER.containers.hoody.com"
# The script is written verbatim, so build it with real newlines.# An unquoted heredoc still expands $SQLITE and $FILES.SCRIPT=$(cat <<EOF// @mode serverless// @cors reflective// @timeout 30000
const SQLITE = "$SQLITE";const FILES = "$FILES";
const result = await fetch(SQLITE + "/api/v1/sqlite/db?db=/hoody/databases/app.db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ transaction: [{ query: "SELECT name, email, role, status FROM users" }] })}).then(r => r.json());
// A failed query, or one cut off at the 10,000-row limit, is refused rather than exported short.const item = result.results?.[0];if (!item?.success || item.truncated) { res.statusCode = 502; return { error: "the report query failed or returned more than 10,000 rows" };}const rows = item.resultSet;const csv = "Name,Email,Role,Status\n" + rows.map(r => [r.name, r.email, r.role, r.status].join(",")).join("\n");
res.setHeader("Content-Type", "text/csv");return csv;EOF)
hoody exec scripts write -c $CONTAINER_ID \ --path "reports/weekly-users.ts" \ --content "$SCRIPT"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,});
const sqliteUrl = `https://${PROJECT_ID}-${CONTAINER_ID}-sqlite-1.${SERVER}.containers.hoody.com`;const filesUrl = `https://${PROJECT_ID}-${CONTAINER_ID}-files-1.${SERVER}.containers.hoody.com`;
await containerClient.exec.scripts.write({ path: 'reports/weekly-users.ts', content: `// @mode serverless// @cors reflective// @timeout 30000
const SQLITE = "${sqliteUrl}";const FILES = "${filesUrl}";
const result = await fetch(SQLITE + "/api/v1/sqlite/db?db=/hoody/databases/app.db", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ transaction: [{ query: "SELECT name, email, role, status, created_at, last_login FROM users ORDER BY last_login DESC" }] })}).then(r => r.json());
// A failed query, or one cut off at the 10,000-row limit, is refused rather than exported short.const item = result.results?.[0];if (!item?.success || item.truncated) { res.statusCode = 502; return { error: "the report query failed or returned more than 10,000 rows" };}const rows = item.resultSet;const headers = "Name,Email,Role,Status,Created,Last Login";const csvRows = rows.map(r => [r.name, r.email, r.role, r.status, r.created_at, r.last_login].join(","));const csv = headers + "\\n" + csvRows.join("\\n");
// Upload is PUT /api/v1/files/{path} with the raw file content as the body.const filename = "weekly-report-" + new Date().toISOString().split("T")[0] + ".csv";const upload = await fetch(FILES + "/api/v1/files/hoody/storage/reports/" + filename, { method: "PUT", headers: { "Content-Type": "application/octet-stream" }, body: csv});if (!upload.ok) { res.statusCode = 502; return { error: "the report could not be archived" };}
res.setHeader("Content-Type", "text/csv");res.setHeader("Content-Disposition", "attachment; filename=" + filename);return csv; `, createDirs: true,});curl -X POST "https://$PROJECT_ID-$CONTAINER_ID-exec-1.$SERVER.containers.hoody.com/api/v1/exec/scripts/write" \ -H "Content-Type: application/json" \ -d '{ "path": "reports/weekly-users.ts", "content": "// @mode serverless\n// @cors reflective\n// @timeout 30000\n\nconst SQLITE = \"https://PROJECT-CONTAINER-sqlite-1.SERVER.containers.hoody.com\";\n\nconst result = await fetch(SQLITE + \"/api/v1/sqlite/db?db=/hoody/databases/app.db\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ transaction: [{ query: \"SELECT name, email, role, status FROM users\" }] })\n}).then(r => r.json());\n\n// A failed query, or one cut off at the 10,000-row limit, is refused rather than exported short.\nconst item = result.results?.[0];\nif (!item?.success || item.truncated) {\n res.statusCode = 502;\n return { error: \"the report query failed or returned more than 10,000 rows\" };\n}\nconst rows = item.resultSet;\nconst csv = \"Name,Email,Role,Status\\n\" + rows.map(r => [r.name, r.email, r.role, r.status].join(\",\")).join(\"\\n\");\n\nres.setHeader(\"Content-Type\", \"text/csv\");\nreturn csv;", "createDirs": true }'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
Writes the report script to reports/weekly-users.ts via hoody-exec. This version returns the CSV directly; it does not also upload it to hoody-files the way the SDK example does.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-exec-1.SERVER.containers.hoody.com/api/v1/exec/scripts/write&method=POST&json={"path":"reports/weekly-users.ts","content":"//%20@mode%20serverless\n//%20@cors%20reflective\n//%20@timeout%2030000\n\nconst%20SQLITE%20=%20\"https://PROJECT-CONTAINER-sqlite-1.SERVER.containers.hoody.com\";\n\nconst%20result%20=%20await%20fetch(SQLITE%20%2B%20\"/api/v1/sqlite/db?db=/hoody/databases/app.db\",%20{\n%20%20method:%20\"POST\",\n%20%20headers:%20{%20\"Content-Type\":%20\"application/json\"%20},\n%20%20body:%20JSON.stringify({%20transaction:%20[{%20query:%20\"SELECT%20name,%20email,%20role,%20status%20FROM%20users\"%20}]%20})\n}).then(r%20=>%20r.json());\n\n//%20A%20failed%20query,%20or%20one%20cut%20off%20at%20the%2010,000-row%20limit,%20is%20refused%20rather%20than%20exported%20short.\nconst%20item%20=%20result.results?.[0];\nif%20(!item?.success%20||%20item.truncated)%20{\n%20%20res.statusCode%20=%20502;\n%20%20return%20{%20error:%20\"the%20report%20query%20failed%20or%20returned%20more%20than%2010,000%20rows\"%20};\n}\nconst%20rows%20=%20item.resultSet;\nconst%20csv%20=%20\"Name,Email,Role,Status\\n\"%20%2B%20rows.map(r%20=>%20[r.name,%20r.email,%20r.role,%20r.status].join(\",\")).join(\"\\n\");\n\nres.setHeader(\"Content-Type\",%20\"text/csv\");\nreturn%20csv;","createDirs":true}&response=transparent Hit the URL and download the report:
curl -o report.csv "https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/reports/weekly-users"The SDK-tab script also saves reports through hoody-files, one file per UTC date (a second run on the same date replaces that day’s copy); the CLI, HTTP, and URL variants only return the CSV.
Schedule the report with hoody-cron
Section titled “Schedule the report with hoody-cron”Run the report automatically every Friday:
hoody cron entries create root \ -c $CONTAINER_ID \ --schedule "0 9 * * 5" \ --command "curl -s https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/reports/weekly-users > /dev/null" \ --comment "Weekly user report generation"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,});
const execUrl = `https://${PROJECT_ID}-${CONTAINER_ID}-exec-1.${SERVER}.containers.hoody.com`;
await containerClient.cron.entries.create('root', { schedule: '0 9 * * 5', // Every Friday at 9 AM command: `curl -s ${execUrl}/reports/weekly-users > /dev/null`, comment: 'Weekly user report generation',});curl -X POST "https://$PROJECT_ID-$CONTAINER_ID-cron-1.$SERVER.containers.hoody.com/api/v1/cron/users/root/entries" \ -H "Content-Type: application/json" \ -d '{ "schedule": "0 9 * * 5", "command": "curl -s https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/reports/weekly-users > /dev/null", "comment": "Weekly user report generation" }'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
Creates the cron entry that runs the report script every Friday at 9 AM.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-cron-1.SERVER.containers.hoody.com/api/v1/cron/users/root/entries&method=POST&json={"schedule":"0%209%20*%20*%205","command":"curl%20-s%20https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/reports/weekly-users%20>%20/dev/null","comment":"Weekly%20user%20report%20generation"}&response=transparent Every Friday at 9 AM, the report generates; when the SDK-tab script is installed, it also saves a copy to the filesystem. The schedule came from one HTTP call, not from editing a crontab or maintaining a server.
Restrict access with proxy permissions
Section titled “Restrict access with proxy permissions”Internal tools should not be public. Lock them down:
# Build the document one field at a time with the granular commands.# Every successful write bumps file_version, so read a fresh ETag before each# mutation; a stale --if-match is rejected with 412.v() { hoody containers proxy permissions get -c $CONTAINER_ID -o json | jq -r '.file_version'; }
# Password protect the containerhoody containers proxy groups password set -c $CONTAINER_ID \ --group-name team \ --auth-username team --auth-password 'internal-tools-2026' \ --salt unique-salt --algorithm sha256 \ --if-match "file:v$(v)"
# Grant the services the group may reach, one program per callfor program in terminal files display exec sqlite; do hoody containers proxy groups permissions set -c $CONTAINER_ID \ --group-name team --program $program --access true --if-match "file:v$(v)"done
hoody containers proxy groups permissions set -c $CONTAINER_ID \ --group-name team --program http --access '[8080]' --if-match "file:v$(v)"
# Everything not granted above is deniedhoody containers proxy default set --default deny -c $CONTAINER_ID --if-match "file:v$(v)"
# Alternative: reset the document, then restrict to office IPhoody containers proxy permissions delete -c $CONTAINER_ID --if-match "file:v$(v)" -y
hoody containers proxy groups ip set -c $CONTAINER_ID \ --group-name office --range 203.0.113.0/24 --if-match "file:v$(v)"
for program in terminal files display exec sqlite; do hoody containers proxy groups permissions set -c $CONTAINER_ID \ --group-name office --program $program --access true --if-match "file:v$(v)"done
hoody containers proxy groups permissions set -c $CONTAINER_ID \ --group-name office --program http --access '[8080]' --if-match "file:v$(v)"
hoody containers proxy default set --default deny -c $CONTAINER_ID --if-match "file:v$(v)"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
// Writes need an If-Match precondition. Read the current file_version from a// prior GET (it is in the response body as `data.file_version`) and pass the// ETag as `ifMatch`, e.g. 'file:v1'.await client.api.proxy.containerPermissions.set(CONTAINER_ID, { project: PROJECT_ID, container: CONTAINER_ID, groups: { team: { type: 'password', username: 'team', password: 'internal-tools-2026', salt: 'unique-salt' } }, permissions: { team: { terminal: true, files: true, display: true, exec: true, sqlite: true, http: [8080] } }, default: 'deny'}, { ifMatch: 'file:v1' });# Writes require an If-Match precondition (read the current file_version via GET first)curl -X PUT "https://api.hoody.com/api/v1/containers/$CONTAINER_ID/proxy/permissions" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -H "If-Match: file:v1" \ -d '{"project":"'$PROJECT_ID'","container":"'$CONTAINER_ID'","groups":{"team":{"type":"password","username":"team","password":"internal-tools-2026","salt":"unique-salt"}},"permissions":{"team":{"terminal":true,"files":true,"display":true,"exec":true,"sqlite":true,"http":[8080]}},"default":"deny"}'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
Replaces the container’s whole permissions document in one write. Read the current file_version from a prior GET first and put it in If-Match — a stale value is rejected with 412.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/containers/CONTAINER_ID/proxy/permissions&method=PUT&bearer_token=TOKEN&header=If-Match:%20file:v1&json={"project":"PROJECT_ID","container":"CONTAINER_ID","groups":{"team":{"type":"password","username":"team","password":"internal-tools-2026","salt":"unique-salt"}},"permissions":{"team":{"terminal":true,"files":true,"display":true,"exec":true,"sqlite":true,"http":[8080]}},"default":"deny"}&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
Every service granted to the team group (exec endpoints, the SQLite UI, terminal access) now requires the password, and anything you did not grant is denied by the default: deny policy.
Share the URLs
Section titled “Share the URLs”To distribute a tool, send its URL.
Admin Dashboard: https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/admin/dashboardWebhook Processor: https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/webhooks/stripeWeekly Report: https://PROJECT-CONTAINER-exec-1.SERVER.containers.hoody.com/reports/weekly-usersSQLite Web UI: https://PROJECT-CONTAINER-sqlite-1.SERVER.containers.hoody.comFile Browser: https://PROJECT-CONTAINER-files-1.SERVER.containers.hoody.comOr create shorter aliases:
hoody proxy aliases create --container-id $CONTAINER_ID --alias "ops-admin" --program exec# Now accessible at: https://ops-admin.SERVER.containers.hoody.com/admin/dashboardNot admin. That word, and api, system and internal, are reserved by the router but not by the create call: the alias is created and the hostname then resolves to nothing. See naming rules.
Whoever opens the URL gets the tool, with no client to install, no deployment on their side, and no access procedure to learn beyond opening a browser.
The pattern
Section titled “The pattern”Every internal tool on Hoody follows the same pattern:
- Write a hoody-exec script: the logic lives in a file that becomes a URL.
- Use hoody-sqlite for data: queries and storage go through HTTP.
- Use hoody-notifications for alerts: desktop notifications appear on the container display when events occur.
- Use hoody-files for output: reports, exports, and archives land on the filesystem.
- Use hoody-cron for scheduling: entries created over HTTP run the script unattended.
- Use proxy permissions for access: restrict by password or IP range.
- Share the URL: sending it is the distribution step.
None of it involves a server to manage, a framework to learn, a deployment pipeline, SSL certificate renewals, Docker images, or Kubernetes manifests. The functions, the database, the file storage, and the schedule are all reached the same way: as URLs.
What’s Next
Section titled “What’s Next”- Building a Full-Stack Application: scale an internal tool into a customer-facing product
- Deploying Autonomous AI Agents: let an AI agent build the next tool
- Hoody Exec Deep Dive: the script-to-API primitive in detail
- SQLite via HTTP: advanced database operations