Metadata-Version: 2.4
Name: sparkdeck-agent
Version: 0.1.2
Summary: Remote AI Command Center agent for NVIDIA DGX Spark
Author: SparkDeck
License-Expression: MIT
License-File: LICENSE
Keywords: ai,dgx,gpu,monitoring,nvidia,spark
Requires-Python: >=3.9
Requires-Dist: click>=8.1.0
Requires-Dist: docker>=7.0.0
Requires-Dist: fastapi>=0.110.0
Requires-Dist: httpx>=0.27.0
Requires-Dist: pydantic>=2.5.0
Requires-Dist: pynvml>=11.5.0
Requires-Dist: uvicorn[standard]>=0.27.0
Requires-Dist: websockets>=12.0
Provides-Extra: dev
Requires-Dist: build>=1.0.0; extra == 'dev'
Requires-Dist: httpx>=0.27.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Requires-Dist: twine>=5.0.0; extra == 'dev'
Description-Content-Type: text/markdown

# SparkDeck Agent

Remote AI Command Center agent for NVIDIA DGX Spark.

A small FastAPI service that runs on the Spark and gives the SparkDeck iOS app
GPU telemetry, Docker container control (start, stop, restart) and discovery of
local LLM endpoints.

## Install

### From the SparkDeck app

Open the device in SparkDeck and use **Agent Management > Install Agent**. The
app connects over SSH and:

1. creates a virtualenv at `~/.sparkdeck-agent-venv`,
2. installs `sparkdeck-agent` from PyPI into it,
3. enables lingering for your user and starts the agent as a systemd user
   service (`sparkdeck-agent start --daemon`),
4. reads the auth token with `sparkdeck-agent token`.

**Update Agent** upgrades the package and restarts the service, **Restart
Agent** restarts it, and **Uninstall Agent** removes the service and the
virtualenv.

### Manually

DGX OS does not allow system-wide `pip install`, so use a virtualenv. Create it
at `~/.sparkdeck-agent-venv`, the location the SparkDeck app looks in:

```bash
python3 -m venv ~/.sparkdeck-agent-venv
source ~/.sparkdeck-agent-venv/bin/activate
pip install sparkdeck-agent

# Keep user services running after you log out (once per machine)
loginctl enable-linger "$USER"

# Install and start the systemd user service
sparkdeck-agent start --daemon
```

Without `--daemon`, `sparkdeck-agent start` runs in the foreground. Use
`--port` to override the port.

The app reads the auth token over SSH by running `sparkdeck-agent token`. You
can print it yourself at any time:

```bash
sparkdeck-agent token
```

## Command line

| Command | What it does |
| --- | --- |
| `sparkdeck-agent start [--port N] [--daemon]` | Run in the foreground, or install and start the systemd user service |
| `sparkdeck-agent stop` | Stop the service |
| `sparkdeck-agent status` | Show version, configuration and service state |
| `sparkdeck-agent token [--reset]` | Print the auth token, or generate a new one |
| `sparkdeck-agent config` | Show the configuration values in effect |

`python -m sparkdeck_agent` works as well.

## Configuration

Settings are read from `~/.config/sparkdeck/config.toml`. The file is parsed
with Python's built-in `tomllib`, which needs Python 3.11 or newer; on older
versions the defaults are used. A file that cannot be parsed, an unknown key
and a value of the wrong type are each logged as a warning that names the file
(for the service: `journalctl --user -u sparkdeck-agent`). An unknown key or a
wrong value is ignored and the rest of the file still applies; a file that
cannot be parsed means all the defaults, so the agent listens on every
interface.

| Key | Default | Meaning |
| --- | --- | --- |
| `host` | `"0.0.0.0"` | Interface to listen on |
| `port` | `8420` | Port to listen on |
| `log_level` | `"info"` | uvicorn log level |
| `gpu_poll_interval` | `1.0` | Seconds between GPU samples on the WebSocket stream |
| `discovery_ports` | `[11434, 8000, 5000, 8080, 8081]` | Local ports scanned for LLM endpoints |

Restart the service after editing the file:
`sparkdeck-agent stop && sparkdeck-agent start --daemon`.

## API

Every `/api` route requires `Authorization: Bearer <token>`. The WebSocket
also accepts a legacy `?token=` query parameter (see the security notes).

| Route | Description |
| --- | --- |
| `GET /api/status` | Hostname, agent version, uptime, GPU, running containers, LLM endpoints |
| `GET /api/gpu/metrics` | Current GPU metrics |
| `WS /api/gpu/stream` | One JSON frame of GPU metrics per `gpu_poll_interval`; a bad token is rejected at the handshake |
| `GET /api/containers` | All Docker containers |
| `POST /api/containers/{id}` | Body `{"action": "start"}`, `{"action": "stop"}` or `{"action": "restart"}` |
| `GET /api/models` | Discovered LLM endpoints |
| `POST /api/chat/proxy` | OpenAI-style chat request forwarded to a local LLM |
| `POST /api/notify/register` | Push notification registration (stub) |

### GPU metrics

The numeric fields (`utilization`, `memory_used`, `memory_total`,
`temperature`, `power_draw`, `fan_speed`, `memory_utilization`) are never
`null`. A value the GPU cannot report, such as fan speed on some systems, is
sent as `0` and its field name is listed in `unavailable`, so clients can show
"N/A" instead of a misleading zero. Memory is the exception: an unavailable
memory reading is sent as `memory_used = 0` and `memory_total = 1` (the total
stays positive), with `"memory"` in `unavailable`.

`memory_kind` is `"unified"` when NVML reports no GPU memory and the figures
come from the system memory in `/proc/meminfo` (the case on a GB10), and
`"dedicated"` otherwise.

## Token and security notes

- **Treat the token like a password.** Anyone who has it can start, stop and
  restart the Docker containers the agent can reach and use the agent as a
  proxy to your local LLMs. It is stored in `~/.config/sparkdeck/token`
  (mode `0600`); do not paste it into chats, tickets or logs.
- **Reset a token** with `sparkdeck-agent token --reset`. The running agent
  re-reads the token file whenever it changes, so the old token is rejected
  from the next request on and no restart is needed. WebSocket streams opened
  with the old token are closed (code 4001) within one poll interval. The
  SparkDeck app (1.0.1 and later) refreshes the token automatically over SSH:
  when the agent rejects its saved token (HTTP 401), the dashboard runs
  `sparkdeck-agent token` over SSH, saves the new token and retries once.
  **Agent Management** shows *Token rejected* with a **Refresh Token** button
  that does the same. Other clients that cached the old token need the new one.
- **The agent speaks plain HTTP** and listens on all interfaces by default.
  Use it on a trusted network such as your LAN or Tailscale, and never expose
  port 8420 to the internet. To narrow it, set `host` in `config.toml`, for
  example your Tailscale address (`host = "100.101.102.103"`). `127.0.0.1`
  makes the agent reachable only from the Spark itself, so the app cannot
  connect. `sparkdeck-agent config` shows the value in effect.
- **WebSocket authentication.** Send the token as `Authorization: Bearer <token>`.
  The older `?token=<token>` query parameter is still accepted for existing app
  versions, but prefer the header: URLs end up in logs and proxies. The agent
  masks `token=...` and `Bearer ...` values in uvicorn's log output.
- **No API docs.** `/docs`, `/redoc` and `/openapi.json` are disabled, so the
  API surface is not published to anyone who reaches the port.

## Development

```bash
pip install -e ".[dev]"
pytest
```
