Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e3c5815
feat: scaffold pi project harness
Canvinus May 22, 2026
365b09c
chore(deps): resolve frontend audit advisories
Canvinus May 22, 2026
1d5fa7c
feat: support pi-only appx runtime
Canvinus May 23, 2026
293ebad
feat: route pi sessions through appx
Canvinus May 23, 2026
21b37a5
feat: manage pi runtime controls
Canvinus May 23, 2026
a728f90
feat: configure pi subscription and custom providers
Canvinus May 23, 2026
d50c8cc
fix: refine pi subscription login fallback
Canvinus May 23, 2026
25de67d
fix: polish pi subscription login panel
Canvinus May 23, 2026
6bd808d
fix: collapse custom provider editor
Canvinus May 23, 2026
d4dbf24
fix: separate pi credential modes
Canvinus May 23, 2026
03b933b
fix: stabilize pi provider row focus
Canvinus May 23, 2026
db7e47a
fix: prevent stale opencode health on pi
Canvinus May 23, 2026
bbb4a72
fix: scope pi agent proxy by project
Canvinus May 23, 2026
941ecd6
fix: support pi content-indexed streaming events
Canvinus May 23, 2026
55b65cd
docs: clarify pi streaming contract
Canvinus May 23, 2026
5fa2afb
fix(appx): harden pi proxy and surface extension approvals
Canvinus May 23, 2026
053bf5b
chore(appx): run agent server in multi project mode
Canvinus May 23, 2026
e4b22ae
fix(appx): recover pi extension approval UI
Canvinus May 23, 2026
e21d8be
fix(appx): route pi agent traffic through egress proxy
Canvinus May 23, 2026
f52cb4a
fix(appx): recover stale pi session state
Canvinus May 23, 2026
c752611
refactor(appx): remove opencode backend
Canvinus May 23, 2026
6aa47e1
fix(appx): clean pi session lifecycle
Canvinus May 23, 2026
4dd3643
fix path after auth refactor
neuromaxer May 27, 2026
07a9b7a
replace agent frontend with agent-chat package
neuromaxer Jun 9, 2026
34a560f
rebuild on change in agent-chat dir
neuromaxer Jun 9, 2026
2533d85
update readme
neuromaxer Jun 9, 2026
bddb18e
Merge pull request #3 from appx-org/pi-switch-refactor
neuromaxer Jun 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 79 additions & 192 deletions CLAUDE.md

Large diffs are not rendered by default.

102 changes: 80 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Appx

Agentic Application Proxy — self-hostable tool to build and host personal apps with AI agents powered by [OpenCode](https://github.com/anomalyco/opencode).
Agentic Application Proxy — self-hostable tool to build and host personal apps with AI agents powered by Pi.

## What it does

Appx is a management shell for running OpenCode agents on a remote server. It provides authentication, TLS termination, a web dashboard, and a reverse proxy — so you can manage projects, chat with agents, and access agent-built apps from a browser over HTTPS.
Appx is a management shell for running coding agents on a remote server. It provides authentication, TLS termination, a web dashboard, and a reverse proxy — so you can manage projects, chat with agents, and access agent-built apps from a browser over HTTPS.

## Architecture

Expand All @@ -13,13 +13,17 @@ Browser
└── HTTPS (single port)
├── / React SPA (embedded in binary)
├── /api/* REST API (auth, projects, settings)
├── /api/opencode/* Reverse proxy → OpenCode server
├── /api/pi/* → agent-server /v1 mirror (agent-chat-ui SDK; project-scoped sessions + models)
├── /api/agent/* → Pi agent-server shared auth/model proxy
└── <project>.<domain> Reverse proxy → agent-built apps
```

Everything is a single Go binary. The React frontend is compiled and embedded at build time. State lives in a SQLite database on disk.
Appx itself is a single Go binary. The React frontend is compiled and embedded
at build time. State lives in a SQLite database on disk.

OpenCode runs as a **separate process** on `localhost:4096` and handles all AI agent work (sessions, tool execution, file editing, terminal). Appx proxies requests to it and adds auth + TLS on top.
Pi is installed as the default agent runtime. systemd runs `agent-server` on
`localhost:4001`; agent-server owns project identity, directories, and sessions
while sharing one set of Pi credentials, and Appx proxies session traffic to it.

**Auth model**: single user, password login, session cookie. On first run a random password is generated and printed to stdout.

Expand Down Expand Up @@ -81,9 +85,7 @@ If you want to use a persistent volume for storage (e.g. Hetzner Cloud Volumes),

The config is saved to `/etc/appx/appx.env` and reused on subsequent runs. To change it later: `sudo nano /etc/appx/appx.env && sudo systemctl restart appx`.

Bootstrap then creates OS users with proper isolation, installs tools (Node.js, OpenCode, Claude Code, uv), sets up systemd services, starts everything, and runs a verification suite.

During Opencode installation you might be prompted "opencode is installed to /usr/local/bin/opencode and may be managed by a package manager". Select `Install anyways? Yes`
Bootstrap then creates OS users with proper isolation, installs tools (Node.js, Pi, Claude Code, uv, and agent-server), sets up systemd services, starts everything, and runs a verification suite. The Appx UI proxies project agent sessions to project-scoped `agent-server` runtimes and proxies provider-auth, subscription login, and custom-provider requests to shared Pi agent settings at `APPX_AGENT_SERVER_URL` (default `http://127.0.0.1:4001`). The Pi agent service runs with `NODE_USE_ENV_PROXY=1`, `HTTPS_PROXY=http://127.0.0.1:9080`, and `NO_PROXY=localhost,127.0.0.1`, so provider traffic goes through the Appx egress allowlist while local agent traffic stays on loopback.

On first run, a random password is written to `{data-dir}/initial_password`. Delete the file after saving your password.

Expand All @@ -93,7 +95,8 @@ Bootstrap installs these tools system-wide so agents can use them in the termina
- **Go** — compiled from the version in `go.mod`
- **Node.js 24 / npm** — JavaScript/TypeScript projects (installed via nvm, pinned to major version 24)
- **uv** — Python version and package management (self-update: `uv self update`)
- **OpenCode** — AI agent backend (pinned version in `deploy/opencode-version`)
- **Pi** — AI coding agent CLI/SDK (pinned version in `deploy/pi-version`)
- **agent-server** — separate Appx org service that exposes Pi sessions over HTTP/SSE for the Agent tab
- **Claude Code** — Claude CLI for terminal use (self-update: `sudo npm update -g @anthropic-ai/claude-code`)

### Updating appx
Expand All @@ -105,11 +108,11 @@ cd /srv/appx
task server:deploy
```

Pulls latest code, rebuilds, installs the binary, updates OpenCode to the pinned version, and restarts both services.
Pulls latest code, rebuilds, installs the binary, updates Pi/agent-server to the pinned versions, and restarts the needed services.

### Updating OpenCode version
### Updating Pi version

Edit `deploy/opencode-version` to the new version, then:
Edit `deploy/pi-version` to the new version, then:

```bash
cd /srv/appx
Expand All @@ -135,27 +138,61 @@ Checks users, permissions, isolation, tools, service files, and runtime. Exits 0
### Troubleshoot

```bash
journalctl -u appx -f # appx logs
journalctl -u opencode -f # opencode logs
journalctl -u appx -f # appx logs
journalctl -u agent-server -f # Pi agent-server logs
```

### Deploy scripts

| File / Script | When | What |
| ------------------------------- | ---------------- | ---------------------------------------------------------- |
| `deploy/bootstrap.sh` | Day 1 | Full setup: users, dirs, tools, build, start, verify |
| `deploy/system-setup.sh` | Infra changes | Users, groups, directories, service files, opencode config |
| `deploy/tools-install.sh` | Tool updates | Go, Node.js 24, OpenCode (pinned), Claude Code, uv |
| `deploy/opencode.json` | Model changes | Default OpenCode model config (copied to opencode home) |
| `deploy/opencode-version` | Version pin | Pinned OpenCode version installed by tools-install |
| `deploy/system-setup.sh` | Infra changes | Users, groups, directories, service files, agent config |
| `deploy/tools-install.sh` | Tool updates | Go, Node.js 24, Pi, agent-server, Claude Code, uv |
| `deploy/agent-server.service` | Pi backend | Systemd unit for project-scoped Pi session service |
| `deploy/pi-version` | Version pin | Pinned Pi version installed by tools-install |
| `deploy/verify-installation.sh` | After any change | Full system verification |

## Local development

OpenCode must be running before starting appx:
### Temporary hack: link the `agent-chat` SDK locally

The Agent tab UI is provided by the `@appx-org/agent-chat-ui` package. Until that
package is published to GitHub Packages, `web/package.json` links it from a
**sibling checkout** via a `file:` dependency (`file:../../agent-chat`), so the
`agent-chat` repo must be cloned next to `appx` (both under the same parent):

```text
<parent>/
├── appx/ ← this repo
└── agent-chat/ ← github.com/appx-org/agent-chat
```

```bash
opencode serve --hostname 127.0.0.1 --port 4096
# one-time, beside your appx checkout
git clone https://github.com/appx-org/agent-chat.git ../agent-chat
# the package ships TypeScript source consumed directly by appx's Vite build,
# so its own deps must be installed once for the symlinked import to resolve
cd ../agent-chat && npm install && cd -
```

`task web` / `task build` then follow the symlink and compile the SDK source as
part of the frontend bundle. Vite dedupes React (see `web/vite.config.ts`) so
the symlink can't pull a second React copy. When the package is published this
`file:` spec swaps back to a semver range and the clone step goes away.

### Run agent-server, then appx

Run the sibling `agent-server` before starting appx. It needs `WORKSPACE_DIR`
pointed at the **same** directory appx uses for projects (co-located dev), since
agent-server owns the project directories and appx's subdomain proxy/terminal
read them from that shared path:

```bash
cd ../agent-server
WORKSPACE_DIR=/path/to/appx-data/projects \
AGENT_SERVER_PORT=4001 \
npm run dev
```

Then start appx with `--host 127.0.0.1.sslip.io` so that subdomain routing and session cookies work correctly across project subdomains. Plain `localhost` has inconsistent cookie-sharing behaviour for subdomains across browsers.
Expand All @@ -179,6 +216,27 @@ All state lives in the data directory (configured during bootstrap, default `/va
| SQLite DB, TLS certs, secrets | `{data}/.appx-internals/` | appx only |
| Project directories | `{data}/projects/` | shared |

Each new project's directory is created and owned by `agent-server` (under its
`WORKSPACE_DIR`, which is the shared `{data}/projects/` path in a co-located
deployment). The project's Pi harness (`{data}/projects/<name>/.pi/`) is owned by
agent-server and currently starts empty — appx no longer scaffolds a prompt,
guardrail extension, or egress skill into it (see
`.superpowers/specs/2026-06-09-project-ownership-and-agent-chat-integration-adr.md`).
Reintroducing harness defaults/templates is tracked as future work.

Pi credentials are configured from Settings. Built-in providers can use stored
API keys or Pi subscription auth where the provider supports it, and custom
providers such as LiteLLM are written to the agent service user's
`models.json` without exposing secret values back to the browser.

The Agent tab is the `@appx-org/agent-chat-ui` SDK talking to Appx's same-origin
`/api/pi/*` mirror, which proxies the `agent-server` `/v1` session contract
(keeping the bearer token server-side). `agent-server` turns all supported Pi
providers into the same session HTTP/SSE contract, so the SDK handles Pi
`message_update` events by `contentIndex` for text and tool-call blocks. Pi
extension UI requests, including Appx guardrail approvals for risky commands, are
delivered over the same session stream and answered through the mirror.

To use a mounted volume, specify the path when bootstrap prompts for "Data directory". Bootstrap automatically creates the subdirectories with correct permissions.

## Subdomain routing without a domain (sslip.io)
Expand Down Expand Up @@ -232,11 +290,11 @@ Bootstrap creates two OS users with a shared `projects` group:

```
appx — runs the appx server, owns DB and TLS certs
opencode — runs OpenCode, cannot access appx data
appx-agent — isolated agent user for Pi tooling, cannot access appx data
projects — shared group, both users read/write project directories
```

Directory permissions prevent OpenCode (and any agent it spawns) from accessing the appx database, TLS keys, or binary. Project directories use setgid so files created by either user are accessible to both.
Directory permissions prevent agent tooling from accessing the appx database, TLS keys, or binary. Project directories use setgid so files created by either user are accessible to both.

## Development

Expand Down
13 changes: 10 additions & 3 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,25 @@ tasks:
- web/package-lock.json
- web/vite.config.ts
- web/tsconfig*.json
# Linked SDK source: @appx-org/agent-chat-ui is a file: dependency on the
# sibling agent-chat repo and is bundled from source, so edits there must
# invalidate this task. In CI/prod the package is installed from the
# registry and this path matches nothing, so the fingerprint quietly falls
# back to the entries above (Task ignores non-matching globs).
- ../agent-chat/src/**/*
generates:
- cmd/appx/web/dist/**/*

build:
desc: Build frontend and Go binary
desc: Build frontend and Go binary (use `task build --force` to force a full rebuild, e.g. after editing the linked agent-chat-ui)
deps: [web]
env:
GOCACHE: "{{.ROOT_DIR}}/.go-build-cache"
cmds:
- go build -o appx ./cmd/appx

local:
desc: Build and run appx in HTTP dev mode with sslip.io subdomain routing
desc: Build and run appx in HTTP dev mode with sslip.io subdomain routing (use `task local --force` to force a full rebuild)
deps: [build]
cmds:
- ./appx --http --host 127.0.0.1.sslip.io
Expand Down Expand Up @@ -66,7 +72,8 @@ tasks:
- sudo install -m 750 -o root -g appx ./appx /usr/local/bin/appx
- sudo ./deploy/tools-install.sh
- sudo ./deploy/system-setup.sh
- sudo systemctl restart opencode appx
- sudo systemctl stop opencode 2>/dev/null || true
- sudo systemctl restart agent-server appx
- sudo ./deploy/verify-installation.sh

server:verify:
Expand Down
61 changes: 27 additions & 34 deletions cmd/appx/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,13 @@ import (
"log"
"os"
"path/filepath"
"time"

"strconv"

"github.com/neuromaxer/appx/internal/agentserver"
"github.com/neuromaxer/appx/internal/auth"
"github.com/neuromaxer/appx/internal/db"
"github.com/neuromaxer/appx/internal/egress"
"github.com/neuromaxer/appx/internal/opencode"
"github.com/neuromaxer/appx/internal/project"
"github.com/neuromaxer/appx/internal/server"
"github.com/neuromaxer/appx/internal/terminal"
Expand Down Expand Up @@ -159,25 +158,18 @@ func main() {
pm := project.NewManager(projectStore, projectRoot)
pm.BaseDomain = baseDomain

// Initialize OpenCode client. OpenCode runs as a separate process on
// localhost:4096. Poll until healthy, then inject the Anthropic API key.
ocClient := opencode.NewClient("http://127.0.0.1:4096")
agentServerURL := envOr("APPX_AGENT_SERVER_URL", "http://127.0.0.1:4001")
agentServerToken := os.Getenv("APPX_AGENT_SERVER_TOKEN")
log.Printf("agent backend: pi (%s)", agentServerURL)

// Resolve Anthropic API key: DB setting takes priority, then env var.
anthropicKey, _ := authStore.GetSetting("anthropic_api_key")
if anthropicKey == "" {
anthropicKey = os.Getenv("ANTHROPIC_API_KEY")
// agent-server owns project runtimes; appx registers/removes projects through it.
pm.Agent = agentserver.NewClient(agentServerURL, agentServerToken)
// Best-effort: re-register known projects so existing projects work and an
// agent-server restart is transparent. Idempotent on the agent-server side.
if err := pm.ReconcileAgentProjects(context.Background()); err != nil {
log.Printf("warning: agent-server project reconcile incomplete: %v", err)
}

// Start OpenCode polling in background — does not block server startup.
go func() {
pollCtx, pollCancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer pollCancel()
if err := ocClient.InjectAPIKey(pollCtx, 2*time.Second, anthropicKey); err != nil {
log.Printf("opencode: startup polling failed: %v", err)
}
}()

webFS, err := fs.Sub(webEmbed, "web/dist")
if err != nil {
log.Fatalf("embed fs: %v", err)
Expand All @@ -191,22 +183,23 @@ func main() {
localManager := terminal.NewLocalManager(512 * 1024) // 512 KB ring buffer

if err := server.Run(server.Config{
Port: *port,
InternalsDir: internalsDir,
DB: database,
AuthStore: authStore,
ProjectManager: pm,
WebFS: webFS,
TLSHosts: hosts,
Domain: *domain,
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
HTTPMode: *httpMode,
BaseDomain: baseDomain,
HostAliases: hosts,
OpenCodeClient: ocClient,
EgressStore: egressStore,
EgressPending: pendingRegistry,
LocalManager: localManager,
Port: *port,
InternalsDir: internalsDir,
DB: database,
AuthStore: authStore,
ProjectManager: pm,
WebFS: webFS,
TLSHosts: hosts,
Domain: *domain,
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
HTTPMode: *httpMode,
BaseDomain: baseDomain,
HostAliases: hosts,
AgentServerURL: agentServerURL,
AgentServerToken: agentServerToken,
EgressStore: egressStore,
EgressPending: pendingRegistry,
LocalManager: localManager,
}); err != nil {
log.Fatal(err)
}
Expand Down
13 changes: 0 additions & 13 deletions deploy/AGENTS.md

This file was deleted.

41 changes: 41 additions & 0 deletions deploy/agent-server.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
[Unit]
Description=Appx Pi Agent Server
Documentation=https://github.com/appx-org/agent-server
After=network.target
Before=appx.service

[Service]
User=appx-agent
Group=appx-agent
UMask=0007

EnvironmentFile=/etc/appx/appx.env
Environment=HOME=/home/appx-agent

# Route provider HTTPS traffic through appx's egress CONNECT proxy on 127.0.0.1:9080.
# This keeps Pi model calls behind the same allowlist and request logging as
# other agent network traffic. Node.js needs NODE_USE_ENV_PROXY=1 before
# fetch/core HTTP clients honor HTTPS_PROXY/NO_PROXY.
Environment=NODE_USE_ENV_PROXY=1
Environment=HTTPS_PROXY=http://127.0.0.1:9080
Environment=NO_PROXY=localhost,127.0.0.1

Environment=AGENT_SERVER_MODE=multi
Environment=PROJECT_DIR=__APPX_PROJECTS_DIR__
Environment=SESSIONS_DIR=/home/appx-agent/.pi/agent/appx-default-sessions
Environment=AGENT_DIR=/home/appx-agent/.pi/agent
Environment=AGENTS_FILE=.pi/AGENTS.md
Environment=AGENT_SERVER_HOST=127.0.0.1
Environment=AGENT_SERVER_PORT=4001

WorkingDirectory=__APPX_PROJECTS_DIR__
ExecStart=/usr/local/bin/agent-server

Restart=on-failure
RestartSec=5

StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
4 changes: 2 additions & 2 deletions deploy/appx.service
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[Unit]
Description=Appx — Agentic Application Proxy
Documentation=https://github.com/neuromaxer/appx
After=network.target opencode.service
After=network.target

[Service]
User=appx
Expand All @@ -13,7 +13,7 @@ AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE

# Ensure directories created by appx (project subdirs) have group-write so the
# opencode user can write files via the shared projects group.
# agent service user can write files via the shared projects group.
UMask=0007

# Server-specific config lives in /etc/appx/appx.env (created by bootstrap).
Expand Down
Loading