A single Val Town HTTP val (Hono router) that exposes small, per-customer
functions behind a per-customer API key, plus a beer.css admin UI to
manage those keys. A function is a plain request→response unit that may or may not invoke an
agent (LangChain + OpenAI) internally. Deno-native, no build step. Deployed to the ttai org.
Pair with uFns for lightweight integration endpoints.
Two surfaces, one val:
| Surface | Path | Auth |
|---|---|---|
| Functions | GET|POST /c/<cname>/<fn> | Bearer key, verified for that cname |
| Admin UI | GET /admin (/ → it) | Val Town OAuth, gated to ADMIN_USERS |
This repo does not use
vtx. Use the nativevtCLI directly. Pushing cleanly is your responsibility.
uAgents/
index.http.ts Hono root app: admin sub-app + customer sub-apps + /logo.svg
val.yml name, files contract, env declarations
deno.json Deno/Val Town compiler + lint config + task shortcuts
.env.sample copy → .env and fill in for local scripts (gitignored)
backend/
core/ cross-cutting infrastructure
shared.ts env(), JSON helpers, mintKey()
app.ts static config: globals() + per-customer Secrets types ← registry
api.ts ApiError, readJsonObject(), error mapping ← mini framework
db.ts SQLite: customer_auth + call_log (8k-row + 14d retention)
auth.ts OAuth admin gate + authenticate(req, cname)
handlers.ts admin view assembly + key-config form save + call queries
agentic-core/ the agent framework — no concrete agents live here
types.ts Agent / AgentCtx / AgentResult contract
runtime.ts LangChain ChatOpenAI wiring (global LLM key) + runAgent()
tool-loop.ts generic model→tools→model loop (reusable by any agent)
prompt.ts shared input-shaping helpers (withContext, str, compact)
agents/ concrete agents + their LangChain tool wrappers
quote-agent.ts the quote agent (structured output)
shopify-mcp-agent.ts unified Shopify agent (Admin tools + Storefront MCP, ≤12 rounds)
shopify-admin-tools.ts LangChain tool wrappers for Shopify Admin reads
integrations/ external-service primitives (one folder per integration)
shopify/
config.ts validate a customer's ShopifySecrets → ShopifyConfig (503 on missing)
token.ts Admin token via client-credentials grant (cached ~24h)
errors.ts map scope/credential failures to visible 4xx (not masked 502)
client.ts adminGql() (fetch, no SDK)
snapshot.ts resolveCustomer(), resolveCustomerGid(), listAbandonedCheckouts()
context.ts deterministic reads for fetch-context
ident-matching.ts shared email/phone matching helpers (used by snapshot.ts + context.ts)
metafields.ts setMetafields(), readMetafields() — generic, no business logic
tags.ts addTags(), removeTags() — generic resource tagging (split out of metafields.ts)
customers/ one auth scope per customer; their functions live here
types.ts Fn / FnCtx / Customer / CustomerSpec contracts
define.ts defineCustomer() — a Hono sub-app per customer
agent-fn.ts agentFn() — bridge any Agent into an FnSpec
builtins.ts reusable functions (echo) customers can register
registry.ts all customers + mountCustomers() ← register here
jjs/
index.ts customer: Jhaji (echo, get-customer-info, get-metafields, shopify-sidekick, post-feedback)
get-customer-info.ts deterministic Shopify read (no LLM)
get-metafields.ts read customer metafields (all or by namespace)
post-feedback.ts POST_SESSION hook — writes call feedback as ttai_call.* metafields
shopify-sidekick.ts function that invokes the Shopify agent
ttai/
index.ts customer: ToughTongue AI (echo, get-a-new-quote)
get-a-new-quote.ts function that invokes the quote agent
scripts/ local Deno scripts for testing against the real store
get-customer-info.ts fetch customer profile + all metafields by email
fetch-context.ts fetch exact order context + recent abandoned checkouts
frontend/
layout.tsx Shell: beer.css doc + top app bar (logo + brand + actions + user)
shared.tsx doc(), UserMenu/LoginLink (navbar), logout, banner
assets.ts inline SVG "uA" logo → served at /logo.svg (favicon + navbar)
admin.tsx admin (Auth + Debug + TTAI Integration tabs) / login / denied
debug.tsx Debug tab: live runner (URL + Authorization + curl + body)
integration.tsx TTAI Integration tab: endpoint URL + params-schema JSON
auth-header.tsx Authorization header display helper (shared by debug + integration)
calls.tsx Recent calls page (customer + fn filters, expandable req/resp)
types.ts admin view models
styles/ modular CSS layers (Next.js-style)
tokens.ts design tokens + semantic M3 aliases (palette is runtime-generated)
base.ts document layout + typography scale
components.ts cards, key row, tabs, chips, table, debug box
index.ts composes layers → GLOBAL_CSS
File naming: only
index.http.tsis the HTTP entry. Val Town infers a file's val-type from reserved name infixes (http,cron,core/api.ts, notcore/http.ts.
The customer (cname) is the first path segment: /c/<cname>/<fn>. Auth is scoped to that cname:
- Send the key as
Authorization: Bearer <key>(orX-API-Key: <key>). - Each customer is a Hono sub-app mounted at
/c/<id>. Its middleware (bound to thatid) callsauthenticate(req, cname)— loads<cname>'s config and compares the bearer token to the stored key in constant time. Rejected when the customer is unconfigured/disabled (403) or the key is missing/wrong (401). The customer module "owns" its name + validation by construction. An outer middleware records every call (success or denied) to the call log — status, latency, and the full request + response bodies (headers stripped, capped). The log is a shared ring buffer: rows older than 14 days are deleted, and once past 8,000 rows the oldest 500 are dropped in a batch. - Admin UI is gated by Val Town OAuth — only usernames in
ADMIN_USERSget in.
Keys are minted in the admin UI (generated on first save, rotatable), stored in SQLite, and shown in
the UI so you can copy them. They carry a readable utt_<cname>_<random> prefix, but routing no
longer depends on the prefix — the URL's cname is authoritative.
The val is public on Val Town — anyone can read the source. Security rests on secrets, not obscurity:
| Surface | Protection | Notes |
|---|---|---|
/c/<cname>/<fn> | Per-customer Bearer key (SQLite) | Constant-time compare; 403 when disabled |
/admin/* | Val Town OAuth + ADMIN_USERS allow-list | All sub-routes including calls.json |
/admin/auth (POST) | OAuth + admin check + registry guard | customer field validated against known registry — rejects unknown ids |
| Shopify creds | Val Town env vars (never in code/logs) | Only read server-side; 503 names missing var |
| Call log bodies | Headers stripped; 2 KB detail / 8 KB body | Admin-only; drop >14d, then 8,000-row cap (prune 500) |
| Key length timing | Early-exit on mismatched length | Acceptable: all keys share the fixed-length prefix |
| Rate limiting | None per-customer beyond Val Town platform | An attacker needs a valid key (admin-issued) to reach agents |
Everything served over the API is a function — a Fn = (ctx) => Response. Functions are
registered per customer and exposed at /c/<cname>/<fn>. A function may invoke an agent or not; the
API doesn't distinguish. A customer's index.ts declares its functions via
defineCustomer({ id, label, fns }); the factory builds a Hono sub-app, wiring auth + call-logging
middleware (bound to that id) and a route per function. Routing, methods, and errors are Hono's
job — there is no hand-rolled path parsing.
Each customer is a folder under backend/customers/. To add one:
- Copy
backend/customers/jjs/tobackend/customers/<id>/and edit the id + label. - Register it in
backend/customers/registry.ts. - In the admin UI, generate + enable its key.
Current functions:
echo(builtin, all customers) — reflects method, query, and body back to the caller.get-a-new-quote(ttai) — invokes a LangChain + OpenAI agent that invents a fresh, original quote. Output is schema-constrained (quote,author,theme) via structured output. Optional JSON body{ "theme": "courage" }; otherwise a theme is picked for you.get-customer-info(jjs,ttai) — deterministic Shopify Admin read (no LLM). Body with at least one ofcustomer_id/customer_email/customer_phone→ profile, pending orders, abandoned checkouts. The same lookup is exposed to the agent asget_customer_info.fetch-context(jjs,ttai) — deterministic Shopify Admin read (no LLM). Body{ order_id, user_email?, user_phone? }returnsuser_phone,abandoned_ts,cart_value, exact order JSON inorder_context, matching unrecovered last-10-day abandoned checkouts inuser_context, and an emptyrelated_itemsarray for response-shape compatibility.shopify-sidekick(jjs,ttai) — full-service Shopify agent (shopify-mcp-agent). Body{ prompt, customer_id?, customer_email?, customer_phone?, cart_id? }. Combines two Admin tools (customer lookup, abandoned checkouts) with five Storefront MCP tools from the shop's/api/mcp(catalog search, product details, cart create/update, policies). Up to 12 tool rounds.
Add a function — drop a file in backend/customers/<id>/ exporting an Fn, then add it to that
customer's fns map. Reusable functions (like echo) live in backend/customers/builtins.ts.
Invoke an agent from a function — see customers/ttai/get-a-new-quote.ts or
customers/jjs/shopify-sidekick.ts: each adapts the request into an AgentCtx and calls
runAgent(). The runtime (agentic-core/runtime.ts) owns all LLM wiring, so agents stay
declarative; add new concrete agents under backend/agents/. Tool-using agents reuse
agentic-core/tool-loop.ts (model→tools→model). The framework lives in agentic-core/; agents/
holds only the concrete agents.
Add an integration — create backend/integrations/<name>/ with a customer-agnostic client +
helpers (see shopify/). Resolve per-customer credentials from <CUSTOMER>_<NAME>_* env so adding
a merchant is config-only.
Set these in the Val Town UI sidebar — never in code (Deno.env.set() is a no-op on Val Town).
All config is static and explicit in backend/core/app.ts (declared in val.yml).
OAUTH_STATE_ENCRYPTION_KEY is read directly by the Val Town OAuth middleware (std/oauth).
Two kinds of config, listed explicitly — no prefix magic:
- Globals —
globals()returns a typed{ openaiKey, adminUsers }. Shared infra (the LLM key, the admin allow-list); used by the runtime + auth only. - Per-customer secrets — each customer has a named type (
SecretsJjs,SecretsTtai) and a resolver (jjsSecrets(),ttaiSecrets()) that reads only that customer's listed env vars (JJS_SHOPIFY_*,TTAI_SHOPIFY_*) into a typed object. A customer'sdefineCustomer({ secrets })resolver runs once; everyFn/Agentfor that customer is handed its own typedctx.secrets(e.g. ajjsfunction sees exactlySecretsJjs). Add a customer = add aSecrets<Name>type + resolver inapp.ts, then pass it in that customer'sindex.ts.
The isolation is a soft convention, not a hard wall — nothing physically stops code from importing
env(). The value is clarity + types: what each customer can read is declared in one place, and the type system keeps ajjsfunction from reaching another customer's (or a global's) secrets.
| Var | Status | Used by | Purpose |
|---|---|---|---|
ADMIN_USERS | required | core/auth.ts (/admin) | Comma-separated Val Town usernames allowed into the admin |
OPENAI_API_KEY | required | agentic-core/runtime.ts | OpenAI key for the LangChain agents (agent-backed functions) |
OAUTH_STATE_ENCRYPTION_KEY | recommended | std/oauth via withValTownAuth | AES-GCM key material that encrypts the admin session cookie |
JJS_SHOPIFY_SHOP | optional | integrations/shopify (jjs) | jjs store hostname — must expose /api/mcp (no scheme) |
JJS_SHOPIFY_CLIENT_ID | optional | integrations/shopify/token.ts | jjs Dev Dashboard app client id — mints Admin tokens |
JJS_SHOPIFY_CLIENT_SECRET | optional | integrations/shopify/token.ts | jjs Dev Dashboard app client secret |
TTAI_SHOPIFY_SHOP | optional | integrations/shopify (ttai) | TTAI dev store hostname — must expose /api/mcp (no scheme) |
TTAI_SHOPIFY_CLIENT_ID | optional | integrations/shopify/token.ts | TTAI Dev Dashboard app client id — mints Admin tokens |
TTAI_SHOPIFY_CLIENT_SECRET | optional | integrations/shopify/token.ts | TTAI Dev Dashboard app client secret |
Notes:
ADMIN_USERSexample:alice,bob. Empty/unset ⇒ nobody can reach/admin(everyone is denied).OPENAI_API_KEYonly matters for agents. Unset ⇒ agent-backed functions return 503; plain functions (e.g.echo) still work.JJS_SHOPIFY_*andTTAI_SHOPIFY_*only matter for their own Shopify functions. A new merchant lists its own vars in asecretsAcme()resolver (app.ts). Unset shop/client creds ⇒ those functions return 503 naming the exact var.- Admin auth (Shopify Dev Dashboard): set
CLIENT_ID+CLIENT_SECRETfrom the app's Settings page.token.tsexchanges them for a ~24h Admin token via the client-credentials grant and caches it. See Admin & Storefront API → Generate tokens. OAUTH_STATE_ENCRYPTION_KEY— if unset, the middleware auto-generates a key and stores it in Val Town blob storage per redirect URI. That works, but set it explicitly so admin sessions survive redeploys and work on self-hosted runtimes. Any high-entropy string works (it's SHA-256'd to a 256-bit AES key); generate one with:
# generate a 32-byte base64 key (macOS: append `| pbcopy` to copy it) openssl rand -base64 32
Then paste the value into the val's Environment Variables sidebar as
OAUTH_STATE_ENCRYPTION_KEY. vt does not manage env vars — they must be set in the Val Town UI.
Scripts in scripts/ run with Deno directly against the real store — no val deployment needed. They
import the backend primitives via relative paths, so they stay in sync automatically.
One-time setup (from exp/vals/uAgents/):
cp .env.sample .env # fill in JJS_SHOPIFY_SHOP, JJS_SHOPIFY_CLIENT_ID, JJS_SHOPIFY_CLIENT_SECRET
Resolves a customer by email and prints their profile, pending orders, abandoned checkouts, and all metafields. Optionally scope metafields to a namespace.
# from exp/vals/uAgents/ deno task get-customer-info mayank.raj.unofficial@gmail.com deno task get-customer-info mayank.raj.unofficial@gmail.com ttai_call # scope to namespace # or directly deno run -A scripts/get-customer-info.ts mayank.raj.unofficial@gmail.com
Resolves an order, backfills the customer's phone when Shopify has it, returns exact order JSON in
order_context, and returns unrecovered abandoned checkouts in user_context only when they match
that order's customer/email/phone and were created in the last 10 days. abandoned_ts and
cart_value are taken from the newest matching abandoned checkout. This function does not call
OpenAI or generate related product recommendations; related_items is intentionally returned as
[] for compatibility.
# from exp/vals/uAgents/ deno task fetch-context --order-id '#1001' deno task fetch-context -c jjs '#1001' --user-email priya.sharma@example.com # or directly deno run -A scripts/fetch-context.ts --order-id '#1001'
Run from inside this directory (exp/vals/uAgents/).
vt status # review what changed vt push # push (forceful — revert via the Val Town website if needed) vt browse # open the deployed val
First push of a brand-new val: vt create uAgents .. To link an existing remote val, vt clone it
to a temp dir and move its .vt/ folder next to this code. After pushing, set the
environment variables in the Val Town UI.
# 1. In /admin, generate + enable a key for "ttai", then: export VT_TTAI_APP_TOKEN="generate-from-admin-ui" export VT_JJS_APP_TOKEN="generate-from-admin-ui" # plain function (echo) curl -X POST https://uagents.val.run/c/ttai/echo \ -H "Authorization: Bearer $VT_TTAI_APP_TOKEN" \ -H "Content-Type: application/json" -d '{"hi":"there"}' # agent-backed function (fresh quote) curl -X POST https://uagents.val.run/c/ttai/get-a-new-quote \ -H "Authorization: Bearer $VT_TTAI_APP_TOKEN" \ -H "Content-Type: application/json" -d '{"theme":"craftsmanship"}' # jjs Shopify sidekick (Admin + Storefront MCP) curl -X POST https://uagents.val.run/c/jjs/shopify-sidekick \ -H "Authorization: Bearer $VT_JJS_APP_TOKEN" \ -H "Content-Type: application/json" \ -d '{"prompt":"Search for hoodies and add the first one to a new cart","customer_email":"mayank.raj.unofficial@gmail.com"}'
Negative paths (auth + routing) — handy as a quick smoke test:
BASE=https://uagents.val.run # missing key → 401 curl -s -o /dev/null -w '%{http_code}\n' -X POST "$BASE/c/ttai/echo" -d '{}' # wrong key → 401 curl -s -o /dev/null -w '%{http_code}\n' -X POST "$BASE/c/ttai/echo" \ -H "Authorization: Bearer nope" -d '{}' # unknown customer → 404 curl -s -o /dev/null -w '%{http_code}\n' -X POST "$BASE/c/nope/echo" \ -H "Authorization: Bearer $VT_TTAI_APP_TOKEN" -d '{}' # unknown function on a real customer → 404 curl -s -o /dev/null -w '%{http_code}\n' -X POST "$BASE/c/ttai/nope" \ -H "Authorization: Bearer $VT_TTAI_APP_TOKEN" -d '{}'