Creating Standalone Adom Services
A standalone service runs on its own dedicated lightweight container, separate from user containers. Use this pattern when:
- The service requires a large dataset (hundreds of MB+) that shouldn't be duplicated per container
- The service needs a persistent server process (HTTP API, background jobs, scheduled updates)
- Multiple containers should share the same data and service instance
Architecture Overview
Each service lives in its own GitHub repo (not in gallia) and runs on a lightweight container (sshd-only, ~7MB RAM idle). Users access it via a Rust CLI that gets installed alongside adom-cli.
Dev Container GitHub Service Container (default-light)
service repo dev --> push --> adom-inc/service-X --> SSH in, git pull
|
node server.js (port XXXX)
|
User Containers ------------- Rust CLI (HTTP) ------------------>|
Step 1: Choose Access Pattern
Ask the user which access pattern they want:
Option A: Rust CLI (recommended for new services)
A compiled Rust binary that wraps the service HTTP API. Installed alongside adom-cli on every user container.
User Container Service Container
Claude --> CLI command HTTP API (port XXXX)
| ^
service-cli search "query" |
| |
HTTP request -------------------> |
|
pushToViewer() --> AV
Best when:
- The API has multiple endpoints
- You want zero-overhead on user containers (no background process)
- You want consistent UX with
adom-cli
Option B: Skill-driven HTTP (e.g., KiCad CLI -- services/kicad-cli)
Skills teach Claude the HTTP endpoints directly. Claude uses curl or fetch.
User Container Service Container
Claude --> reads skill HTTP API (port XXXX)
| ^
curl via Bash ------- HTTP ------> |
Best when:
- The API surface is small and stable (handful of endpoints)
- The service is primarily used by one or two skills
- You want fewer moving parts (no CLI to build)
Option C: MCP Server (legacy pattern)
MCP stdio process on each user container proxying to the remote API. Not recommended for new services -- use Rust CLI instead. Legacy MCP services (JLCPCB, Mouser, DigiKey, Wiki) still work but won't be the pattern going forward.
| Rust CLI | Skill-driven HTTP | MCP Server (legacy) | |
|---|---|---|---|
| User container overhead | Zero (compiled binary) | Zero | Node.js stdio process |
| Discovery | CLI skill + --help | Requires skill activation | Automatic MCP tools |
| Validation | CLI argument parsing | Relies on skill instructions | JSON Schema |
| Distribution | Binary pulled during install | Skill deployed by gallia | MCP server.js in gallia |
| Reference | adom-cli | services/kicad-cli | jlcpcb/mcp/ |
Step 2: Create the Service GitHub Repo
Each service gets its own repo at adom-inc/service-<name>. This keeps gallia focused on user-container tooling.
Create the repo on GitHub (or via the Adom API):
# Via GitHub CLI (if authenticated)
gh repo create adom-inc/service-<name> --private --description "Adom <name> service"
# Or via Adom API
API_KEY=$(cat /var/run/adom/api-key)
curl -s -X POST 'https://carbon.adom.inc/user/repos' \
-b "session_token=$API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name":"service-<name>","description":"<description>","private":true}'
Step 3: Write the Service Code
Structure the repo like this:
service-<name>/
server.js # HTTP API server
package.json # Dependencies
service.json # Auto-start manifest
start.sh # Idempotent start script
setup.sh # First-time setup (install deps, init DB, etc.)
README.md # Service documentation
service.json -- Service Manifest
This is read by auto-start and watchdog scripts. It's the single source of truth for how to run the service.
{
"name": "my-service",
"service": "my-tag",
"description": "What this service does",
"port": 8XXX,
"health": "http",
"start": "node server.js",
"cwd": ".",
"log": "/tmp/my-service.log"
}
| Field | Required | Description |
|---|---|---|
name | yes | Service name (used in logs and watchdog output) |
service | yes | Service filter tag (see Service Filtering below) |
description | no | Human-readable description |
port | yes | Port the service listens on |
health | yes | "http" (GET /health returns 200) or "tcp" (port open check) |
start | yes | Shell command to start the service |
cwd | yes | Working directory -- "." means same dir as service.json |
log | no | Log file path (defaults to /tmp/<name>.log) |
server.js -- HTTP API Server
Use Node.js built-in http module (no Express). Key patterns:
import { createServer } from 'http';
const PORT = parseInt(process.env.MY_SERVICE_PORT || '8XXX', 10);
const server = createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
// Health endpoint (required)
if (url.pathname === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, service: 'my-service', uptime: process.uptime() }));
return;
}
// Landing page (serve HTML when browser visits)
if (url.pathname === '/' && req.headers.accept?.includes('text/html')) {
const proto = req.headers['x-forwarded-proto'] || 'https';
const host = req.headers['x-forwarded-host'] || req.headers.host;
const baseUrl = `${proto}://${host}`;
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(landingPageHTML(baseUrl));
return;
}
// Your API endpoints here...
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`[my-service] Running on port ${PORT}`);
});
package.json -- Standalone Dependencies
{
"name": "service-my-service",
"version": "1.0.0",
"type": "module",
"private": true,
"dependencies": { },
"scripts": { "start": "node server.js" }
}
start.sh -- Idempotent Start Script
Always the same 3-step pattern:
#!/bin/bash
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
LOG_FILE="/tmp/my-service.log"
PORT=8XXX
# 1. Check if already running (idempotent)
if curl -sf --max-time 2 http://127.0.0.1:${PORT}/health > /dev/null 2>&1; then
echo "[my-service] Server already running on port ${PORT}"
exit 0
fi
# 2. Install deps if needed
if [ ! -d "$SCRIPT_DIR/node_modules" ]; then
cd "$SCRIPT_DIR" && npm install --production 2>&1 | tail -3
fi
# 3. Start with nohup
cd "$SCRIPT_DIR"
nohup /usr/bin/node server.js >> "$LOG_FILE" 2>&1 &
echo "[my-service] Server started (PID $!), logging to $LOG_FILE"
Important: Use /usr/bin/node (explicit path) -- PATH may not be set at boot time.
setup.sh -- First-Time Setup
Run once after cloning the repo on the service container:
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "[setup] Installing dependencies..."
cd "$SCRIPT_DIR" && npm install --production
# Database initialization (if needed)
# node setup-db.js
# Set GALLIA_SERVICE so auto-start knows what to run
echo "GALLIA_SERVICE=my-tag" | sudo tee -a /etc/environment
export GALLIA_SERVICE=my-tag
echo "[setup] Starting service..."
bash "$SCRIPT_DIR/start.sh"
echo "[setup] Verifying health..."
sleep 2
curl -sf http://127.0.0.1:${PORT}/health && echo " OK" || echo " FAILED"
Step 4: Create a Lightweight Container
Use adom-cli to create a default-light container (sshd-only).
Important: Containers must be associated with a repo at creation time -- this cannot be changed later. Always create the repo first (Step 2), get its ID, then pass --repo-id.
# Get the Adom repo ID (from Step 2, or look it up)
adom-cli carbon user repos # find the "id" field for your service repo
# Create container with repo association
adom-cli carbon containers create \
--image-id 69b43b3e58d13e5ce628cdc5 \
--class small \
--ssh \
--repo-id <ADOM_REPO_ID>
Note: --repo-id takes the Adom repo ID (from carbon.adom.inc), not a GitHub repo ID.
When associated with a repo, the SSH username includes the repo name (e.g. john-service-wiki-rk6euj7525tq), making it easy to identify. Without --repo-id, the container is orphaned with a random slug and won't appear in repo container listings.
Note the SSH credentials from the response:
{
"ssh_credentials": {
"command": "ssh [email protected]",
"hostname": "adom.cloud",
"port": 22,
"username": "john-service-wiki-rk6euj7525tq"
}
}
Prerequisites: You need an SSH key registered with your Adom account. If you don't have one:
# Generate key if missing
[ ! -f ~/.ssh/id_ed25519 ] && ssh-keygen -t ed25519 -C "adom" -f ~/.ssh/id_ed25519 -N ""
# Register if none on Adom
[ "$(adom-cli carbon user ssh-keys)" = "[]" ] && \
adom-cli carbon user ssh-key-add --display-name "auto" "$(cat ~/.ssh/id_ed25519.pub)"
Step 5: Deploy the Service
SSH into the container, clone the repo, and run setup:
# SSH in
ssh -o StrictHostKeyChecking=accept-new [email protected]
# Clone the service repo (GitHub auth may be needed)
cd ~ && git clone https://github.com/adom-inc/service-<name>.git service
# Run first-time setup
cd service && bash setup.sh
The default-light image includes node, git, python3, and curl. If your service needs additional tools, install them in setup.sh.
Add a cron @reboot for auto-start
(crontab -l 2>/dev/null; echo "@reboot cd /home/adom/service && bash start.sh >> /tmp/my-service-boot.log 2>&1") | crontab -
Expose a public URL (optional)
If the service needs to be reachable from other containers:
# From your dev container (not from inside the service container)
adom-cli carbon containers port-add <slug> --port 8XXX --prefix service-name
This creates a public URL like service-name-abc123.adom.cloud.
Step 6: Wire Up Consumer Access
Back on the dev container, make the service accessible to all users.
For Rust CLI services (Option A)
- Create a Rust CLI project that wraps the HTTP API (follow
adom-clipatterns) - Add the CLI binary to gallia's
install.mjsso it gets pulled during installation - Create a skill at
gallia/skills/<service-name>/SKILL.mddocumenting the CLI commands
For skill-driven services (Option B)
Document the HTTP endpoints in a gallia skill. Include the service's public URL:
## API Endpoints
Base URL: `https://service-name-abc123.adom.cloud`
### Search
GET /search?q=<query>&limit=10
Hardcode the service URL
Whether in a CLI or skill, hardcode the public URL as the default:
https://<service-url>.adom.cloud/
Create a port mapping with adom-cli carbon containers port-add. For example:
https://service-name-abc123.adom.cloud
Service Filtering (GALLIA_SERVICE)
The GALLIA_SERVICE environment variable (set in /etc/environment) tells auto-start scripts which services to manage on this container.
How filtering works
GALLIA_SERVICE value | What starts |
|---|---|
| (unset or empty) | Defaults to "local" -- only services tagged "local" |
"wiki" | Only services tagged "wiki" |
"local,wiki" | Services tagged "local" OR "wiki" |
"all" | Every service regardless of tag |
For service containers: Set GALLIA_SERVICE=<tag> in /etc/environment so only that service starts on boot.
For user containers: Leave unset. Defaults to "local".
Setting the env var
echo 'GALLIA_SERVICE=my-tag' | sudo tee -a /etc/environment
Deploying Updates
# From your dev container, SSH into the service container and update
ssh [email protected] "cd ~/service && git pull origin main && npm install && pkill -f 'node.*server.js'; bash start.sh"
Or run each step interactively:
ssh [email protected]
cd ~/service
git pull origin main
npm install
pkill -f 'node.*server.js' || true
bash start.sh
Scheduled Maintenance (Cron)
For daily database updates or cleanup:
#!/bin/bash
# /etc/cron.daily/update-my-service-db
CURL_ARGS=(-sfL -o "$STAGING_PATH" --etag-save "$ETAG_FILE")
[ -f "$ETAG_FILE" ] && CURL_ARGS+=(--etag-compare "$ETAG_FILE")
HTTP_CODE=$(curl -w '%{http_code}' "${CURL_ARGS[@]}" "$DB_URL" 2>/dev/null || true)
if [ "$HTTP_CODE" = "304" ]; then exit 0; fi
mv "$STAGING_PATH" "$DB_PATH"
pkill -HUP -f "node server.js" # zero-downtime reload
Adom Viewer Integration
If your service returns results that benefit from rich display, register it in the AV Service Dashboard.
Edit /home/adom/gallia/viewer/viewer/index.html:
- Add an entry to the
SERVICE_REGISTRYJS object with:name,icon,desc,container,port,repo,repoPath,mcpName, and optionallylocal: true - Add an
<option value="svc-<key>">to the<optgroup>"Standalone Services" - Add a skill-card
<div>to the About page
The health check, URL generation, and dashboard rendering are all automatic from the registry entry.
Testing
Create test.js in the service repo:
- Hit local HTTP API directly (use
127.0.0.1, notlocalhost) - Push styled HTML results to Adom Viewer
- Assert
count > 0before filter assertions --.every()on empty array returnstrue - Test with real user queries, not just exact IDs
Reference Implementations
| Service | Repo | Pattern | Container Image | Notes |
|---|---|---|---|---|
| JLCPCB | gallia (legacy) | MCP (legacy) | default-vscode | SQLite DB, daily cron |
| Mouser | gallia (legacy) | MCP (legacy) | default-vscode | HTTP proxy to Mouser API |
| DigiKey | gallia (legacy) | MCP (legacy) | default-vscode | OAuth client_credentials |
| KiCad CLI | gallia (legacy) | Skill-driven HTTP | default-vscode | Heavy install (~600MB KiCad 9) |
| Wiki | gallia (legacy) | MCP (legacy) | default-vscode | SQLite + FTS5, asset uploads |
| New services | adom-inc/service-X | Rust CLI | default-light | Recommended pattern |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| SSH "Permission denied" | No SSH key registered | Generate and register: ssh-keygen && adom-cli carbon user ssh-key-add |
| Service starts but health check fails | Wrong port | Match port in service.json to what server.js listens on |
| Service doesn't survive reboot | No auto-start | Add @reboot cron entry (see Step 5) |
npm install fails | Missing system deps | Install via sudo apt-get install <pkg> in setup.sh |
| Can't access from user container | No port mapping or wrong URL | Add port mapping: adom-cli carbon containers port-add |
| Container not found after creation | Provisioning delay | Wait 15-30 seconds, then retry SSH |