# 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):

```bash
# 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.

```json
{
  "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:

```javascript
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

```json
{
  "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:

```bash
#!/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:

```bash
#!/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`.

```bash
# 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:
```json
{
  "ssh_credentials": {
    "command": "ssh john-service-wiki-rk6euj7525tq@adom.cloud",
    "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:

```bash
# 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:

```bash
# SSH in
ssh -o StrictHostKeyChecking=accept-new john-abc123def456@adom.cloud

# 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

```bash
(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:

```bash
# 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)

1. Create a Rust CLI project that wraps the HTTP API (follow `adom-cli` patterns)
2. Add the CLI binary to gallia's `install.mjs` so it gets pulled during installation
3. Create a skill at `gallia/skills/<service-name>/SKILL.md` documenting the CLI commands

### For skill-driven services (Option B)

Document the HTTP endpoints in a gallia skill. Include the service's public URL:

```markdown
## 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

```bash
echo 'GALLIA_SERVICE=my-tag' | sudo tee -a /etc/environment
```

## Deploying Updates

```bash
# From your dev container, SSH into the service container and update
ssh john-abc123def456@adom.cloud "cd ~/service && git pull origin main && npm install && pkill -f 'node.*server.js'; bash start.sh"
```

Or run each step interactively:

```bash
ssh john-abc123def456@adom.cloud
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:

```bash
#!/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`:

1. Add an entry to the `SERVICE_REGISTRY` JS object with: `name`, `icon`, `desc`, `container`, `port`, `repo`, `repoPath`, `mcpName`, and optionally `local: true`
2. Add an `<option value="svc-<key>">` to the `<optgroup>` "Standalone Services"
3. 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`, not `localhost`)
- Push styled HTML results to Adom Viewer
- **Assert `count > 0` before filter assertions** -- `.every()` on empty array returns `true`
- 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 |
