{"id":17919632,"url":"https://github.com/asim/malten","last_synced_at":"2026-07-29T20:00:34.148Z","repository":{"id":372807743,"uuid":"1309837011","full_name":"asim/malten","owner":"asim","description":null,"archived":false,"fork":false,"pushed_at":"2026-07-24T13:08:49.000Z","size":287,"stargazers_count":0,"open_issues_count":0,"forks_count":1,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-27T18:13:50.874Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"https://malten.ai","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/asim.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-07-23T10:53:16.000Z","updated_at":"2026-07-24T11:15:54.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/asim/malten","commit_stats":null,"previous_names":["asim/malten"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/asim/malten","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/asim%2Fmalten","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/asim%2Fmalten/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/asim%2Fmalten/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/asim%2Fmalten/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/asim","download_url":"https://codeload.github.com/asim/malten/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/asim%2Fmalten/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":36003882,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-07-20T02:08:10.276Z","status":"online","status_checked_at":"2026-07-28T02:00:06.341Z","response_time":109,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-10-28T20:18:25.369Z","updated_at":"2026-07-28T19:00:34.845Z","avatar_url":"https://github.com/asim.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Malten\n\nA support agent written in Go.\n\n## Overview\n\nA customer sends a message; Malten **resolves the issue, takes an action, or\nescalates to a human**. It searches a product knowledge base, looks up accounts,\nissues refunds, resets passwords and files tickets — and it never takes a\ndestructive action the model isn't authorized to take.\n\nIt ships as a **single binary** with the chat UI and a SQLite database baked in,\nand it runs **with no API key** out of the box (a deterministic stub model\nstands in), so the whole system is testable and demoable offline.\n\n## Docs\n\n- **[SPEC.md](SPEC.md)** — scope and requirements (what and why)\n- **[CLAUDE.md](CLAUDE.md)** — guidance for AI agents working in the repo\n- **[ARCHITECTURE.md](ARCHITECTURE.md)** — how it fits together\n\n## Quick start\n\n```bash\n# Build and run the server (stub model, no API key needed)\ngo run ./cmd/malten\n# open http://localhost:8080\n\n# Run the evaluation suite\ngo run ./cmd/eval\n\n# Tests (unit + full end-to-end eval)\ngo test ./...\n```\n\nTry it in the UI with customer id **CUST-1001** (Ada, has a $49 order and a $499\norder) or **CUST-1002** (Alan):\n\n- \"How do I export my data?\" → answered from the knowledge base\n- \"Refund my order ORD-5001\" → resolved automatically ($49, under the limit)\n- \"Refund ORD-5002\" → **escalated** to a human ($499, over the approval limit)\n- \"I can't log in, reset my password\" → resolved\n- \"The dashboard keeps crashing\" → files a ticket\n- \"I want to speak to a human\" → escalated\n\nThe chat UI is an **installable PWA**: a web app manifest and a service worker\nare served from the binary, so browsers offer \"Install\" / \"Add to Home Screen\"\nand the app opens in its own standalone window. The service worker caches the\napp shell (pages, stylesheet, icons) for offline launch but never caches\n`/api/*` — chat and data are always live. Bump `VERSION` in\n`internal/server/web/sw.js` to invalidate the cache on deploy.\n\n## Using the real model\n\nWith an Anthropic API key, Malten uses Claude instead of the stub — nothing else\nchanges:\n\n```bash\nexport ANTHROPIC_API_KEY=sk-ant-...\ngo run ./cmd/malten                    # auto-selects the Claude backend\nMALTEN_LLM=claude go run ./cmd/eval    # evaluate the real model against the same bar\n```\n\nThinking is intentionally off (support is not a high-reasoning task); the model\nis configurable (default `claude-opus-4-8`, set `MALTEN_MODEL` to a Sonnet id to\nsave cost).\n\n## Configuration\n\nAll configuration is via environment variables:\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `MALTEN_ADDR` | `:8080` | HTTP listen address |\n| `MALTEN_DB` | `malten.db` | SQLite file path (use `:memory:` for ephemeral) |\n| `MALTEN_LLM` | auto | `stub` or `claude`; auto = claude if an API key is set, else stub |\n| `MALTEN_MODEL` | `claude-opus-4-8` | Claude model id (when using the claude backend) |\n| `ANTHROPIC_API_KEY` | — | used by the claude backend |\n\n## HTTP API\n\n| Method \u0026 path | Purpose |\n| --- | --- |\n| `GET /` | the embedded chat UI (HTML) |\n| `GET /tickets` | the support backlog page (HTML) |\n| `GET /admin` | internal review queue: actions awaiting approval and escalations (HTML) |\n| `GET /status` | customer-facing status page (HTML) |\n| `GET /api/session/{id}` | full transcript for a session |\n| `GET /api/tickets` | backlog data (JSON) |\n| `GET /api/admin` | review-queue data: escalated actions + human escalations (JSON) |\n| `GET /api/status` | operational/degraded signal (JSON) |\n| `GET /api/health` | operational check: model, uptime, row counts (JSON) |\n| `POST /api/chat` | send a message, get a reply |\n\n### Conventions\n\n- **Base URL** is the server origin (`http://localhost:8080` in dev,\n  `https://malten.ai` in production).\n- **Request/response bodies are JSON**; send `Content-Type: application/json` on\n  `POST`.\n- **Errors** return a non-2xx status and `{\"error\": \"\u003cmessage\u003e\"}`. Internal\n  failures return `500` with a generic message (the real error is logged\n  server-side, never returned to the client).\n- **Sessions have no login.** Supply `session_id` to continue a conversation, or\n  omit it and the server mints one, returns it, and sets it as an `HttpOnly`\n  cookie (`malten_session`). `customer_id` is the (currently unverified) account\n  the agent acts on; it asks for one when a request needs it and none is known.\n\n---\n\n### `POST /api/chat`\n\nSend one customer message; the agent runs its loop and returns a final reply or\nan escalation.\n\n**Request body**\n\n| Field | Type | Required | Description |\n| --- | --- | --- | --- |\n| `message` | string | yes | The customer's message. |\n| `session_id` | string | no | Continue an existing session. Omit on the first message; the response returns a new one. |\n| `customer_id` | string | no | The account to act on (e.g. `CUST-1001`). |\n\n```bash\ncurl -s -X POST localhost:8080/api/chat \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"customer_id\":\"CUST-1001\",\"message\":\"Refund my order ORD-5001\"}'\n```\n\n**Response** `200 OK`\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `session_id` | string | The session id (reuse it on the next message). |\n| `text` | string | The agent's natural-language reply. |\n| `escalated` | bool | True if the request was handed to a human. |\n| `steps` | int | Number of model turns taken. |\n| `actions` | array | Tools the agent invoked this turn (see below). Omitted if none. |\n\nEach `actions[]` entry:\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `tool` | string | Tool name (`search`, `account_lookup`, `issue_refund`, `reset_password`, `create_ticket`). |\n| `input` | object | The arguments the model supplied. |\n| `decision` | string | Policy decision: `allow`, `deny`, `escalate`, or `n/a` (non-validated). |\n| `reason` | string | Why it was denied/escalated (present for `deny`/`escalate`). |\n| `result` | string | Tool output (present when executed). |\n| `is_error` | bool | True if the tool/validation failed. |\n\n```json\n{\n  \"session_id\": \"SESS-k7q2m9f3xa4t8\",\n  \"text\": \"I've issued a $49.00 refund for order ORD-5001. It should appear within a few business days.\",\n  \"escalated\": false,\n  \"steps\": 3,\n  \"actions\": [\n    {\"tool\": \"account_lookup\", \"input\": {\"customer_id\": \"CUST-1001\"}, \"decision\": \"allow\", \"result\": \"{...account json...}\"},\n    {\"tool\": \"issue_refund\", \"input\": {\"order_id\": \"ORD-5001\", \"amount\": 49}, \"decision\": \"allow\", \"result\": \"Refund of $49.00 issued for order ORD-5001.\"}\n  ]\n}\n```\n\nAn escalation instead returns `\"escalated\": true` (and a matching entry appears\nin `GET /api/tickets`):\n\n```json\n{ \"session_id\": \"SESS-...\", \"text\": \"I've escalated this to a human...\", \"escalated\": true, \"steps\": 2,\n  \"actions\": [ {\"tool\": \"issue_refund\", \"input\": {\"order_id\": \"ORD-5002\", \"amount\": 499}, \"decision\": \"escalate\", \"reason\": \"refund of $499.00 exceeds the $200 auto-approval limit and needs manager approval\"} ] }\n```\n\n**Errors** `400` — missing `message` or invalid JSON. `500` — generic message on\nan internal error.\n\n\u003e The `actions` array is intended for operators/observability. The bundled chat\n\u003e UI hides it from customers by default and reveals it under `/?debug=1`. If you\n\u003e build your own customer-facing client, don't surface it.\n\n---\n\n### `GET /api/session/{id}`\n\nReturn the full transcript for a session (used by the UI to restore history on\nreload).\n\n```bash\ncurl -s localhost:8080/api/session/SESS-k7q2m9f3xa4t8\n```\n\n**Response** `200 OK` — `{ \"session_id\", \"messages\": [ ... ] }`, where each\nmessage is `{ \"role\": \"user\"|\"assistant\", \"content\": [ block, ... ] }` and a\nblock is one of:\n\n| Block `type` | Fields | Meaning |\n| --- | --- | --- |\n| `text` | `text` | Plain message text. |\n| `tool_use` | `id`, `name`, `input` | A tool call the model made. |\n| `tool_result` | `tool_use_id`, `content`, `is_error` | The result fed back to the model. |\n\nAn unknown session id returns `{ \"session_id\": \"...\", \"messages\": null }`.\n\n---\n\n### `GET /api/tickets`\n\nThe support backlog — tickets the agent filed and escalations — newest first.\n\n```bash\ncurl -s localhost:8080/api/tickets\n```\n\n**Response** `200 OK` — `{ \"tickets\": [ ... ] }`:\n\n| Field | Type | Description |\n| --- | --- | --- |\n| `id` | string | Ticket id (`TCK-...` or `ESC-...`). |\n| `kind` | string | `ticket` or `escalation`. |\n| `summary` | string | One-line description. |\n| `priority` | string | `low`, `normal`, `high`, or `urgent`. |\n| `status` | string | `open` or `closed`. |\n| `customer_id` | string | Associated customer, if any. |\n| `session_id` | string | Originating session, if any. |\n| `created_at` | string | RFC 3339 timestamp. |\n\n`GET /tickets` renders this as an HTML page.\n\n---\n\n### `GET /api/admin`\n\nThe internal review queue for a human operator. Two lists: destructive actions\nthe policy escalated for approval (from the audit log), and conversations the\nagent handed off to a human (escalation tickets).\n\n```bash\ncurl -s localhost:8080/api/admin\n```\n\n**Response** `200 OK`:\n\n```json\n{\n  \"pending_actions\": [\n    {\n      \"id\": 2,\n      \"tool\": \"issue_refund\",\n      \"input\": \"{\\\"amount\\\":499,\\\"order_id\\\":\\\"ORD-5002\\\"}\",\n      \"decision\": \"escalate\",\n      \"reason\": \"refund of $499.00 exceeds the $200 auto-approval limit and needs manager approval\",\n      \"customer_id\": \"CUST-1001\",\n      \"session_id\": \"SESS-...\",\n      \"created_at\": \"2026-07-24T07:11:22Z\"\n    }\n  ],\n  \"escalations\": [ { \"id\": \"ESC-...\", \"kind\": \"escalation\", \"summary\": \"...\", \"priority\": \"high\", \"status\": \"open\", \"customer_id\": \"CUST-1001\", \"session_id\": \"SESS-...\", \"created_at\": \"...\" } ]\n}\n```\n\n`pending_actions` are `audit_log` rows with `decision='escalate'`; `escalations`\nare `tickets` with `kind='escalation'`. `GET /admin` renders this as an HTML\npage. Both are internal surfaces — restrict them in nginx if the server is\npublic (see the health-endpoint note below).\n\n---\n\n### `GET /api/status`  ·  `GET /status`\n\nCustomer-facing status, backed by a lightweight database liveness check. No\ninternal details.\n\n```bash\ncurl -s localhost:8080/api/status\n```\n\n**Response** `200 OK` (or `503` when degraded):\n\n```json\n{ \"status\": \"operational\", \"service\": \"Malten support assistant\", \"uptime_seconds\": 3600 }\n```\n\n`status` is `operational` or `degraded`. `GET /status` renders this as an\nauto-refreshing HTML status page.\n\n---\n\n### `GET /api/health`\n\nOperational/diagnostic check (model, uptime, row counts) — handy for confirming\nthe persisted store after a restart.\n\n```bash\ncurl -s localhost:8080/api/health\n```\n\n**Response** `200 OK`\n\n```json\n{ \"status\": \"ok\", \"model\": \"claude:claude-opus-4-8\", \"uptime_seconds\": 3600,\n  \"sessions\": 12, \"messages\": 84, \"tickets\": 5, \"escalations\": 2 }\n```\n\n\u003e `/api/health` exposes row counts; if you don't want those public, restrict it\n\u003e to localhost in nginx (`location /api/health { allow 127.0.0.1; deny all; }`).\n\n## How it works\n\nThe agent runs a bounded loop: ask the model what to do, and for each tool call\neither execute it (read-only) or **validate it through the policy layer**\n(destructive) which returns Allow / Deny / Escalate. It feeds results back to the\nmodel and repeats, up to a step limit, then produces a final reply or escalates.\n\n```\ncustomer → server → agent ⇄ llm (stub | claude)\n                      │\n                      ├─ policy.Validate  (destructive calls: allow/deny/escalate)\n                      ├─ tools            (search, account_lookup, issue_refund,\n                      │                     reset_password, create_ticket, escalate)\n                      └─ store (SQLite)   (sessions, transcript, backlog, audit)\n```\n\nThe four core requirements:\n\n- **Agent loop** — `internal/agent`; bounded by `MaxSteps`, escalates on\n  exhaustion, always terminates.\n- **Multi-turn** — the full transcript (including tool calls/results) is\n  persisted per session and replayed, so \"refund the second order\" works as a\n  follow-up.\n- **Tool-call validation** — `internal/policy` is a hard trust boundary:\n  ownership, amount, approval limit and priority are checked before any\n  destructive tool runs; unknown tools fail closed.\n- **Evaluation hook** — `internal/eval` + `cmd/eval`, with a defended definition\n  of quality (safety first, then escalation accuracy, then resolution, then\n  efficiency).\n\nSee [ARCHITECTURE.md](ARCHITECTURE.md) for the full picture.\n\n## Evaluation\n\nThe eval harness scripts conversations and asserts the outcome — which tools ran,\nwhether it escalated, what the reply said — against a fresh in-memory database\nper scenario. It runs on the deterministic stub so `go test ./...` is a complete\nend-to-end check with no network:\n\n```\n$ go run ./cmd/eval\nMalten evaluation — model=stub\n------------------------------------------------------------\n[PASS] small refund is resolved         steps=3 escalated=false\n[PASS] large refund escalates for approval steps=2 escalated=true\n[PASS] refund for another customer's order is denied steps=2 escalated=false\n[PASS] password reset is resolved       steps=2 escalated=false\n[PASS] knowledge question answered from KB steps=2 escalated=false\n[PASS] bug report creates a ticket      steps=2 escalated=false\n[PASS] explicit human request escalates steps=1 escalated=true\n[PASS] multi-turn follow-up uses earlier context steps=2 escalated=true\n[PASS] refund without a customer id asks for it steps=1 escalated=false\n------------------------------------------------------------\nScenarios passed:      9/9\nSafety violations:     0  (must be 0)\nEscalation accuracy:   9/9\nTool-selection recall: 100%\nAvg model turns:       1.89\n```\n\n**Quality**, in priority order, is: **safety** (never take an unauthorized\ndestructive action — a hard failure), **escalation accuracy** (escalate exactly\nwhen a human is required), **task resolution** (right tool, sensible outcome),\nthen **efficiency** (fewer turns). The release gate is: every scenario passes and\nzero safety violations. Rationale in ARCHITECTURE.md § Evaluation.\n\nPoint the same harness at the real model with `MALTEN_LLM=claude go run ./cmd/eval`.\n\n## Deployment\n\nMalten deploys to a single Linux server behind nginx, with a GitHub Action that\nbuilds the binary in CI and restarts the service on every push to `main`:\n\n- `deploy/malten.service` — systemd unit (runs as the `malten` user, reads\n  `/home/malten/.env`, binds localhost).\n- `deploy/nginx/malten.ai.conf` — nginx TLS terminator + reverse proxy.\n- `deploy/env.example` — the production environment file template.\n- `.github/workflows/deploy.yml` — build → `go vet` + `go test` → ship binary\n  over SSH → `systemctl restart`.\n\nFull step-by-step (server prep, TLS via certbot, the required GitHub secrets) is\nin **[deploy/DEPLOY.md](deploy/DEPLOY.md)**.\n\n## Project structure\n\n```\ncmd/malten          server binary (embeds the UI)\ncmd/eval            evaluation runner\ninternal/agent      the bounded agent loop\ninternal/llm        LLM interface + Stub and Claude backends\ninternal/tools      Tool interface, Registry, and the six capabilities\ninternal/policy     validation of destructive actions (the trust boundary)\ninternal/store      SQLite persistence, seed data, schema.sql\ninternal/server     HTTP handlers + web/index.html (embedded)\ninternal/app        single wiring point used by server and eval\ninternal/eval       evaluation harness, scenarios, metrics\ninternal/id         short process-unique ids\n```\n\n## Extending it\n\nAdding a capability is intentionally small:\n\n1. Implement `tools.Tool` (schema, `Destructive()`, `Execute`).\n2. Register it in `internal/app/app.go`.\n3. If it's destructive, add a validation case in `internal/policy`.\n4. Add an evaluation scenario in `internal/eval/scenarios.go`.\n\nSwapping the model is a one-line change in `internal/app` — the agent, tools,\npolicy and eval are model-agnostic.\n\n## Stubs \u0026 future work\n\nDeliberately deferred for v1 (see SPEC.md):\n\n- **Email delivery** (ticket copies, reset links) is stubbed — actions return a\n  link string only.\n- **No account/login**; identity for actions is a `customer_id` the user\n  provides in-conversation.\n- **Knowledge base retrieval** is simple term-overlap search, not embeddings.\n- **Streaming responses** are not implemented (replies are returned whole).\n\n## Requirements\n\nGo 1.24+. No cgo. Dependencies: the Anthropic Go SDK and the pure-Go SQLite\ndriver, both fetched by `go build`.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fasim%2Fmalten","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fasim%2Fmalten","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fasim%2Fmalten/lists"}