Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.

uAgents

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:

SurfacePathAuth
FunctionsGET|POST /c/<cname>/<fn>Bearer key, verified for that cname
Admin UIGET /admin (/ → it)Val Town OAuth, gated to ADMIN_USERS

This repo does not use vtx. Use the native vt CLI directly. Pushing cleanly is your responsibility.


Layout

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.ts is the HTTP entry. Val Town infers a file's val-type from reserved name infixes (http, cron, email, …), so the mini-framework lives in core/api.ts, not core/http.ts.


Auth model (per-customer key, cname in the URL)

The customer (cname) is the first path segment: /c/<cname>/<fn>. Auth is scoped to that cname:

  • Send the key as Authorization: Bearer <key> (or X-API-Key: <key>).
  • Each customer is a Hono sub-app mounted at /c/<id>. Its middleware (bound to that id) calls authenticate(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_USERS get 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.

Security properties (code is public)

The val is public on Val Town — anyone can read the source. Security rests on secrets, not obscurity:

SurfaceProtectionNotes
/c/<cname>/<fn>Per-customer Bearer key (SQLite)Constant-time compare; 403 when disabled
/admin/*Val Town OAuth + ADMIN_USERS allow-listAll sub-routes including calls.json
/admin/auth (POST)OAuth + admin check + registry guardcustomer field validated against known registry — rejects unknown ids
Shopify credsVal Town env vars (never in code/logs)Only read server-side; 503 names missing var
Call log bodiesHeaders stripped; 2 KB detail / 8 KB bodyAdmin-only; drop >14d, then 8,000-row cap (prune 500)
Key length timingEarly-exit on mismatched lengthAcceptable: all keys share the fixed-length prefix
Rate limitingNone per-customer beyond Val Town platformAn attacker needs a valid key (admin-issued) to reach agents

Customers & functions

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:

  1. Copy backend/customers/jjs/ to backend/customers/<id>/ and edit the id + label.
  2. Register it in backend/customers/registry.ts.
  3. 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 of customer_id / customer_email / customer_phone → profile, pending orders, abandoned checkouts. The same lookup is exposed to the agent as get_customer_info.
  • fetch-context (jjs, ttai) — deterministic Shopify Admin read (no LLM). Body { order_id, user_email?, user_phone? } returns user_phone, abandoned_ts, cart_value, exact order JSON in order_context, matching unrecovered last-10-day abandoned checkouts in user_context, and an empty related_items array 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.


Environment variables

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:

  • Globalsglobals() 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's defineCustomer({ secrets }) resolver runs once; every Fn / Agent for that customer is handed its own typed ctx.secrets (e.g. a jjs function sees exactly SecretsJjs). Add a customer = add a Secrets<Name> type + resolver in app.ts, then pass it in that customer's index.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 a jjs function from reaching another customer's (or a global's) secrets.

VarStatusUsed byPurpose
ADMIN_USERSrequiredcore/auth.ts (/admin)Comma-separated Val Town usernames allowed into the admin
OPENAI_API_KEYrequiredagentic-core/runtime.tsOpenAI key for the LangChain agents (agent-backed functions)
OAUTH_STATE_ENCRYPTION_KEYrecommendedstd/oauth via withValTownAuthAES-GCM key material that encrypts the admin session cookie
JJS_SHOPIFY_SHOPoptionalintegrations/shopify (jjs)jjs store hostname — must expose /api/mcp (no scheme)
JJS_SHOPIFY_CLIENT_IDoptionalintegrations/shopify/token.tsjjs Dev Dashboard app client id — mints Admin tokens
JJS_SHOPIFY_CLIENT_SECREToptionalintegrations/shopify/token.tsjjs Dev Dashboard app client secret
TTAI_SHOPIFY_SHOPoptionalintegrations/shopify (ttai)TTAI dev store hostname — must expose /api/mcp (no scheme)
TTAI_SHOPIFY_CLIENT_IDoptionalintegrations/shopify/token.tsTTAI Dev Dashboard app client id — mints Admin tokens
TTAI_SHOPIFY_CLIENT_SECREToptionalintegrations/shopify/token.tsTTAI Dev Dashboard app client secret

Notes:

  • ADMIN_USERS example: alice,bob. Empty/unset ⇒ nobody can reach /admin (everyone is denied).
  • OPENAI_API_KEY only matters for agents. Unset ⇒ agent-backed functions return 503; plain functions (e.g. echo) still work.
  • JJS_SHOPIFY_* and TTAI_SHOPIFY_* only matter for their own Shopify functions. A new merchant lists its own vars in a secretsAcme() resolver (app.ts). Unset shop/client creds ⇒ those functions return 503 naming the exact var.
  • Admin auth (Shopify Dev Dashboard): set CLIENT_ID + CLIENT_SECRET from the app's Settings page. token.ts exchanges 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.


Local scripts

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

get-customer-info

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

fetch-context

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'

Local → Val Town with vt

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.


Try it

# 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 '{}'