Apps — build once, use again

The agent writes a small program into the workspace, wires resources into it, and runs it — today and next week. Blueprints, bindings, artifacts and the authority model.

6 min read
10 sections
Edit this page

An agent that answers a question leaves you with an answer. An agent that builds an app leaves you with something you can run again tomorrow, with different input, without a model in the loop.

python
agent.run(
    "Create an app named revenue_by_region that totals `amount` per `region` "
    "from a CSV, then use it on bookings.csv."
)
text
├─ Tool group: Listed blueprints, built the app revenue_by_region, used the app
│  ├─ list_blueprints                               completed
│  ├─ create_app                                    completed
│  └─ use_app                                       completed   62ms

├─ Artifact: Revenue by region
│  Page · /project/.shipit/apps/revenue_page/revenue.html

└─ Final answer
   EMEA 204,000 · AMER 494,000 · APAC 109,000.

What it looks like

Revenue analysis Live
Where did revenue land by region? Build me something I can send.
Listed blueprints, built the app revenue_dash✓ create_app · 2.0ms2 tools · 4.1ms
Used the app revenue_dash✓ use_app · 61.4ms — {"totals": {"AMER": 494000, …}}1 tool · 61.4ms
Revenue by region · Q2 FY2025Page · Click to open
AMER leads at $494K, EMEA $204K, APAC $109K. The dashboard is yours to send.
24,613 tokens · claude-opus-5

Next quarter, that dashboard is one store.run("revenue_dash", …) away — no model, no tokens, same numbers.


The four tools

They ship as builtins, storing under <project>/.shipit/apps.

ToolWhat it does
list_blueprintsWhat can I start from, and what already exists?
create_appWrite one into the workspace — from a blueprint, from code, or both
set_app_bindingWire a resource into it
use_appRun it with input, and get its return value back

The split is deliberate. Creating writes files; running executes code the model wrote; wiring changes what an app is allowed to reach. Those are different acts with different risks, and each gets its own row in the transcript.


What an app is

A directory with a manifest and an app.py exporting one function:

python
# .shipit/apps/revenue_by_region/app.py
"""Total `amount` per `region`. Expects: path (str) — the CSV to read."""

import csv
from pathlib import Path


def run(input, env):
    with Path(input["path"]).open() as handle:
        rows = list(csv.DictReader(handle))

    totals = {}
    for row in rows:
        totals[row["region"]] = totals.get(row["region"], 0) + float(row["amount"])
    return {"totals": totals, "grand_total": sum(totals.values())}

Its module docstring is not decoration: when a run fails, use_app returns it to the agent, so a KeyError becomes a correction rather than a guess.


Blueprints

Starting points, copied into the new app:

BlueprintProduces
reportA Markdown table from records
csv_summaryRow counts from a CSV, optionally grouped by a column
pageA self-contained HTML page
dashboardHeadline cards and a bar chart, with a share-of-total table
sheetA spreadsheet view — column letters, row numbers, flagged cells
workflowA pipeline as boxes and connectors, with a live log
python
from shipit_agent.apps import AppStore

store = AppStore(".shipit/apps", workdir=".")
store.create("revenue_dash", title="Revenue by region", blueprint="dashboard")

store.run("revenue_dash", {
    "rows": rows,
    "title": "Revenue by region · Q2 FY2025",
    "subtitle": "warehouse · fy25_bookings",
    "output": "revenue.html",
})

Every blueprint is self-contained: no CDN, no web fonts, no <script>, no @import. An artifact that needs the network is not one you can email someone, and a test asserts it. Data is escaped, so a <script> in a cell stays text.

dashboard guesses its own label and value fields, so a model that passes only rows still gets a chart. sheet takes highlight={"Status": {"At risk": 1}} to tint the cells someone needs to look at.


Running an app

python
result = store.run("revenue_by_region", {"path": "bookings.csv"})

result.ok          # True
result.value       # {"totals": {...}, "grand_total": 807000.0}
result.stdout      # anything it printed
result.error       # traceback, if it raised
result.env_calls   # how many resource calls it made

store.run() rather than run_app(): the store knows where apps run — the project the agent works in, not the app's own install directory. An app given path="bookings.csv" means the file the agent has been working with.


Artifacts

An app that writes a file declares it, and the run emits an event:

json
{"type": "artifact_created", "path": "/project/revenue.html",
 "title": "Revenue by region", "kind": "Page", "tool": "use_app"}

Which every surface draws as a card — Revenue by region · Page · Click to open. Kinds come from the extension: Doc, Sheet, Page, Data, Image, Deck, Code, PDF, Archive, File.

Two rules keep the cards honest:

  • Only declared paths. Scraping them out of tool text would invent an artifact from any string with a slash.
  • Only side-effecting tools. read_file reports the path it read; treating that as an artifact would draw a card for a file you already had.

Any tool of your own gets cards for free by putting path (or paths) in its result metadata.


Authority

An app is never more privileged than the agent that wrote it.

  • It runs in a subprocess with a scrubbed environment — no credentials.
  • Its env reaches the parent over the same capability bridge code mode uses, so every resource call is gated by the permission engine, the contracts and the approval queue exactly as the equivalent tool call would be.
  • It sees only what it was wired:
python
store.bind("revenue_by_region", source="WAREHOUSE", as_name="DB")
python
def run(input, env):
    rows = env.DB.call(query="SELECT region, amount FROM bookings")

An unwired binding is not merely refused — it is not in env at all. A test asserts the app cannot even name it.

Children of an app get read-only tools by default, because a sub-agent that can write is a side effect nobody reviewed.


Running one without an agent

That is the point of building one:

python
from shipit_agent.apps import AppStore

store = AppStore(".shipit/apps", workdir=".")
for month in ("q2.csv", "q3.csv"):
    print(month, store.run("revenue_by_region", {"path": month}).value)

No LLM call, no tokens, no drift.


Contracts

ToolRead-onlyAuto-approvableBlocks the agent
list_blueprintsyes
create_appnoyes (fs.write)yes
set_app_bindingnoyes (fs.write)yes
use_appnoneveryes

use_app runs code the model wrote, so the tag says nothing about what a particular call does — it can never be auto-approved, and the agent always waits for the result.


See also