Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
58 changes: 58 additions & 0 deletions .github/workflows/clawdash-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: clawdash Image

on:
push:
branches:
- master
tags:
- "v*"
pull_request:
paths:
- "cmd/clawdash/**"
- "internal/clawdash/**"
- "dockerfiles/clawdash/**"
- "go.mod"
- "go.sum"
- ".github/workflows/clawdash-image.yml"
workflow_dispatch:

permissions:
contents: read
packages: write

jobs:
build-and-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: docker/setup-qemu-action@v3

- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/clawdash
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=tag
type=sha,format=short

- uses: docker/build-push-action@v6
with:
context: .
file: dockerfiles/clawdash/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,13 @@ claw build -t quickstart-assistant ./agents/assistant
claw up -f claw-pod.yml -d

# Verify
claw ps -f claw-pod.yml # assistant + cllama-passthrough both running
claw ps -f claw-pod.yml # assistant + cllama both running
claw health -f claw-pod.yml # both healthy
```

Open **http://localhost:8081** — the cllama governance proxy dashboard. Watch every LLM call in real time: which agent, which model, token counts, cost.
Open **http://localhost:8181** — the cllama governance proxy dashboard. Watch every LLM call in real time: which agent, which model, token counts, cost.

Open **http://localhost:8082** — the Clawdapus Dash fleet dashboard. View live service health, topology wiring, and per-service drill-down status.

Message `@quickstart-bot` in your Discord server. The bot responds through the proxy — it has no direct API access. The dashboard updates live.

Expand All @@ -59,6 +61,23 @@ claw up -d
claw agent add researcher
```

## Dashboard Screenshots

Fleet view with integrated costs status:

![Clawdapus Dash Fleet](docs/screenshots/clawdash-fleet-costs.png)

If a cllama build does not emit `GET /costs/api`, Clawdapus Dash surfaces an explicit "cost emission not available yet" state instead of linking to a dead page.
API data is authoritative; log-derived cost estimation is opt-in via `CLAWDASH_COST_LOG_FALLBACK=1`.

Topology view:

![Clawdapus Dash Topology](docs/screenshots/clawdash-topology.png)

Service detail view:

![Clawdapus Dash Detail](docs/screenshots/clawdash-detail.png)

---

## Install
Expand Down Expand Up @@ -248,9 +267,9 @@ When a reasoning model tries to govern itself, the guardrails are part of the sa
- **Identity resolution:** Single proxy serves an entire pod. Bearer tokens resolve which agent is calling.
- **Cost accounting:** Extracts token usage from every response, multiplies by pricing table, tracks per agent/provider/model.
- **Audit logging:** Structured JSON on stdout — timestamp, agent, model, latency, tokens, cost, intervention reason.
- **Operator dashboard:** Real-time web UI at port 8081 — agent activity, provider status, cost breakdown.
- **Operator dashboard:** Real-time web UI at host port 8181 by default (container `:8081`) — agent activity, provider status, cost breakdown.

The reference implementation is [`cllama-passthrough`](https://github.com/mostlydev/cllama-passthrough) — a zero-dependency Go binary that implements the transport layer (identity, routing, cost tracking). Future proxy types (`cllama-policy`) will add bidirectional interception: evaluating outbound prompts and amending inbound responses against the agent's behavioral contract.
The reference implementation is [`cllama`](https://github.com/mostlydev/cllama) — a zero-dependency Go binary that implements the transport layer (identity, routing, cost tracking). Future proxy types (`cllama-policy`) will add bidirectional interception: evaluating outbound prompts and amending inbound responses against the agent's behavioral contract.

See the [cllama specification](./docs/CLLAMA_SPEC.md) for the full standard.

Expand Down
132 changes: 132 additions & 0 deletions cmd/claw/compose_manifest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package main

import (
"encoding/json"
"fmt"
"path/filepath"
"sort"
"strings"

"github.com/mostlydev/clawdapus/internal/clawdash"
"github.com/mostlydev/clawdapus/internal/cllama"
"github.com/mostlydev/clawdapus/internal/driver"
"github.com/mostlydev/clawdapus/internal/pod"
)

func writePodManifest(runtimeDir string, p *pod.Pod, resolved map[string]*driver.ResolvedClaw, proxies []pod.CllamaProxyConfig) (string, error) {
manifest := buildPodManifest(p, resolved, proxies)
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return "", fmt.Errorf("encode pod manifest: %w", err)
}

path := filepath.Join(runtimeDir, "pod-manifest.json")
if err := writeRuntimeFile(path, append(data, '\n'), 0644); err != nil {
return "", fmt.Errorf("write pod manifest %q: %w", path, err)
}
return path, nil
}

func buildPodManifest(p *pod.Pod, resolved map[string]*driver.ResolvedClaw, proxies []pod.CllamaProxyConfig) *clawdash.PodManifest {
out := &clawdash.PodManifest{
PodName: p.Name,
Services: make(map[string]clawdash.ServiceManifest, len(p.Services)),
}

names := make([]string, 0, len(p.Services))
for name := range p.Services {
names = append(names, name)
}
sort.Strings(names)

for _, name := range names {
svc := p.Services[name]
manifest := clawdash.ServiceManifest{
ImageRef: svc.Image,
Count: 1,
}
if svc.Claw != nil && svc.Claw.Count > 0 {
manifest.Count = svc.Claw.Count
}

if rc, ok := resolved[name]; ok && rc != nil {
manifest.ClawType = rc.ClawType
manifest.Agent = rc.Agent
manifest.Models = cloneStringMap(rc.Models)
manifest.Handles = rc.Handles
manifest.PeerHandles = rc.PeerHandles
manifest.Surfaces = toSurfaceManifest(rc.Surfaces)
manifest.Skills = resolvedSkillNames(rc.Skills)
manifest.Invocations = append([]driver.Invocation(nil), rc.Invocations...)
manifest.Cllama = append([]string(nil), rc.Cllama...)
if rc.Count > 0 {
manifest.Count = rc.Count
}
} else if svc.Claw != nil {
manifest.Handles = svc.Claw.Handles
manifest.Surfaces = toSurfaceManifest(svc.Claw.Surfaces)
manifest.Cllama = append([]string(nil), svc.Claw.Cllama...)
}

out.Services[name] = manifest
}

if len(proxies) > 0 {
out.Proxies = make([]clawdash.ProxyManifest, 0, len(proxies))
for _, proxy := range proxies {
out.Proxies = append(out.Proxies, clawdash.ProxyManifest{
ProxyType: proxy.ProxyType,
ServiceName: cllama.ProxyServiceName(proxy.ProxyType),
Image: proxy.Image,
})
}
sort.Slice(out.Proxies, func(i, j int) bool {
return out.Proxies[i].ServiceName < out.Proxies[j].ServiceName
})
}

return out
}

func toSurfaceManifest(in []driver.ResolvedSurface) []clawdash.SurfaceManifest {
if len(in) == 0 {
return nil
}
out := make([]clawdash.SurfaceManifest, 0, len(in))
for _, s := range in {
out = append(out, clawdash.SurfaceManifest{
Scheme: s.Scheme,
Target: s.Target,
AccessMode: s.AccessMode,
Ports: append([]string(nil), s.Ports...),
ChannelConfig: s.ChannelConfig,
})
}
return out
}

func resolvedSkillNames(in []driver.ResolvedSkill) []string {
if len(in) == 0 {
return nil
}
out := make([]string, 0, len(in))
for _, sk := range in {
name := strings.TrimSpace(sk.Name)
if name == "" {
continue
}
out = append(out, name)
}
return out
}

func cloneStringMap(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
128 changes: 128 additions & 0 deletions cmd/claw/compose_manifest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package main

import (
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/mostlydev/clawdapus/internal/driver"
"github.com/mostlydev/clawdapus/internal/pod"
)

func TestBuildPodManifestIncludesResolvedState(t *testing.T) {
p := &pod.Pod{
Name: "fleet",
Services: map[string]*pod.Service{
"bot": {
Image: "bot:latest",
Claw: &pod.ClawBlock{Count: 2},
},
"redis": {
Image: "redis:7",
},
},
}

resolved := map[string]*driver.ResolvedClaw{
"bot": {
ServiceName: "bot",
ImageRef: "bot:latest",
ClawType: "openclaw",
Agent: "AGENTS.md",
Models: map[string]string{
"primary": "anthropic/claude-sonnet-4-20250514",
},
Count: 2,
Handles: map[string]*driver.HandleInfo{
"discord": {ID: "123", Username: "fleet-bot"},
},
PeerHandles: map[string]map[string]*driver.HandleInfo{
"analyst": {
"discord": {ID: "456", Username: "analyst-bot"},
},
},
Surfaces: []driver.ResolvedSurface{
{Scheme: "channel", Target: "discord"},
{Scheme: "service", Target: "redis", Ports: []string{"6379"}},
},
Skills: []driver.ResolvedSkill{
{Name: "risk-limits.md", HostPath: "/host/risk-limits.md"},
},
Invocations: []driver.Invocation{
{Schedule: "0 * * * *", Message: "status pulse", Name: "status", To: "123"},
},
Cllama: []string{"passthrough"},
},
}
proxies := []pod.CllamaProxyConfig{
{ProxyType: "passthrough", Image: "ghcr.io/mostlydev/cllama:latest"},
}

got := buildPodManifest(p, resolved, proxies)
if got.PodName != "fleet" {
t.Fatalf("expected podName=fleet, got %q", got.PodName)
}
if len(got.Services) != 2 {
t.Fatalf("expected 2 services, got %d", len(got.Services))
}

botSvc := got.Services["bot"]
if botSvc.ClawType != "openclaw" {
t.Fatalf("expected claw type openclaw, got %q", botSvc.ClawType)
}
if botSvc.Count != 2 {
t.Fatalf("expected count 2, got %d", botSvc.Count)
}
if len(botSvc.Skills) != 1 || botSvc.Skills[0] != "risk-limits.md" {
t.Fatalf("expected skill name-only serialization, got %v", botSvc.Skills)
}
if len(botSvc.Cllama) != 1 || botSvc.Cllama[0] != "passthrough" {
t.Fatalf("expected cllama passthrough, got %v", botSvc.Cllama)
}

redisSvc := got.Services["redis"]
if redisSvc.ClawType != "" {
t.Fatalf("expected non-claw service clawType empty, got %q", redisSvc.ClawType)
}
if redisSvc.Count != 1 {
t.Fatalf("expected non-claw count 1, got %d", redisSvc.Count)
}

if len(got.Proxies) != 1 {
t.Fatalf("expected 1 proxy, got %d", len(got.Proxies))
}
if got.Proxies[0].ServiceName != "cllama" {
t.Fatalf("expected proxy service cllama, got %q", got.Proxies[0].ServiceName)
}
}

func TestWritePodManifestWritesJSONFile(t *testing.T) {
dir := t.TempDir()
p := &pod.Pod{
Name: "test-pod",
Services: map[string]*pod.Service{
"bot": {Image: "bot:latest"},
},
}

path, err := writePodManifest(dir, p, nil, nil)
if err != nil {
t.Fatalf("writePodManifest returned error: %v", err)
}
if path != filepath.Join(dir, "pod-manifest.json") {
t.Fatalf("unexpected manifest path %q", path)
}

raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read manifest: %v", err)
}
var decoded map[string]interface{}
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("manifest is not valid json: %v", err)
}
if decoded["podName"] != "test-pod" {
t.Fatalf("expected podName=test-pod, got %v", decoded["podName"])
}
}
Loading