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