Loading the notebook…
Loading the notebook…
Notebooks · Workspace, live UI and observability · 74
Watch a real agent work: human-verb transcript rows, deferred approvals, lockdown, delegated sub-agents, connections, and SSE frames — all against bedrock-mantle/google.gemma-4-26b-a4b.
Its imports and arguments are checked against shipit-agent 3.0. The outputs were saved when it was written, for an earlier release.
Nothing below is truncated. Where a cell prints, it prints the whole thing — every argument, every byte a tool returned, the complete approval description. Where a cell draws the live panel, it is built with
output_limit=None, so each call's real output is there in full behind its disclosure row, scrolling in its own box rather than stretching the page.
The bedrock-mantle provider lives in the DRK codebase and speaks the
OpenAI Chat Completions API over SigV4. We load the module directly —
importing the package would pull in Django and Celery, which this repo has
no reason to depend on.
Needs: AWS credentials on the default chain (~/.aws/credentials), and
pip install boto3 litellm.
import importlib.util
import sys
from pathlib import Path
PROVIDER = Path(
"~/Documents/MYWORK/AFTDRK/CACHE/DRK_CACHE_BACK"
"/drk_cache/llm/bedrock_mantle_provider.py"
)
MODEL = "bedrock-mantle/google.gemma-4-26b-a4b"
spec = importlib.util.spec_from_file_location("bedrock_mantle_provider", PROVIDER)
module = importlib.util.module_from_spec(spec)
sys.modules["bedrock_mantle_provider"] = module
spec.loader.exec_module(module)
module.ensure_registered()
from shipit_agent.llms import LiteLLMChatLLM
def llm():
"""A fresh adapter per agent — they hold no shared state, but it keeps
each section independent if you re-run cells out of order."""
return LiteLLMChatLLM(model=MODEL)
import boto3
print("identity :", boto3.client("sts").get_caller_identity()["Arn"])
print("model :", MODEL)identity : arn:aws:iam::275210565507:user/0x99-bedrock model : bedrock-mantle/google.gemma-4-26b-a4b
# A few fake tools so the sections below are deterministic and free of
# side effects. Real tools work identically — these just record their calls.
from shipit_agent.tools.base import ToolOutput
CALLS = []
def tool(name, output="ok", *, description=None, sensitive=False,
properties=None, required=None, credential_key=None):
"""Build a one-off tool. `sensitive=True` triggers lockdown (§5)."""
class T:
def __init__(self):
self.name = name
self.description = description or f"The {name} tool."
self.prompt_instructions = ""
if credential_key:
self.credential_key = credential_key
def schema(self):
return {
"type": "function",
"function": {
"name": name,
"description": self.description,
"parameters": {
"type": "object",
"properties": properties or {
"path": {"type": "string", "description": "File path"}
},
"required": required if required is not None else ["path"],
},
},
}
def run(self, context, **kwargs):
CALLS.append((name, kwargs))
metadata = {}
if sensitive:
metadata = {"sensitive": True, "sensitive_reason": "customer PII"}
return ToolOutput(text=output, metadata=metadata)
return T()
print("helpers ready")helpers ready
Everything below this section uses the actual built-in tools against
this repository — real read_file, real grep_files, real glob_files,
real bash. Nothing is stubbed.
Safety, before we hand a model a shell:
PermissionEngine(allow=[...read-only...], deny=["bash", "write_file", ...])
— the model can look at anything and change nothingfrom shipit_agent import Agent
from shipit_agent.builtins import get_builtin_tool_map
from shipit_agent.permissions import PermissionDecision, PermissionEngine
def _repo_root(start: Path) -> Path:
"""Walk up to the repo. A notebook's cwd is `notebooks/`, not the root,
so `Path.cwd()` points the file tools one directory too deep and every
read comes back "File not found"."""
for candidate in [start, *start.parents]:
if (candidate / "shipit_agent" / "__init__.py").exists():
return candidate
raise RuntimeError(f"Could not locate the shipit_agent repo from {start}")
REPO = str(_repo_root(Path.cwd()))
BUILTINS = get_builtin_tool_map(llm=None, project_root=REPO)
# The reading tools, pointed at this repo.
READERS = [BUILTINS[n] for n in
("read_file", "grep_files", "glob_files", "workspace_files")]
# NOTE: `deny` outranks `allow`, so `deny=["*"]` would deny the readers too.
# To mean "these and nothing else", allow-list them and flip the default.
READ_ONLY = PermissionEngine(
allow=["read_file", "grep_files", "glob_files", "workspace_files"],
default_decision=PermissionDecision.DENY,
)
print("repo :", REPO)
print("tools :", [t.name for t in READERS])
print("policy: read anything, change nothing")
for name in ("read_file", "glob_files", "bash", "write_file"):
print(f" {name:14} {READ_ONLY.check(name, {}).decision.value}")repo : ~/Documents/MYWORK/ai_developer/others/shipit_agent
tools : ['read_file', 'grep_files', 'glob_files', 'workspace_files']
policy: read anything, change nothing
read_file allow
glob_files allow
bash deny
write_file denyglob_files to find things, grep_files to search them, read_file to look.
The transcript collapses the search into one row and shows the real targets.
from shipit_agent.narrate import watch
agent = Agent(
llm=llm(), tools=READERS, permissions=READ_ONLY,
auto_use_skills=False, max_iterations=6,
)
# output_limit=None: every call's real output, in full. Each one scrolls in
# its own box, so nothing is hidden and the page does not grow without bound.
watch(
agent,
"Use glob_files with pattern 'shipit_agent/narrate/*.py' to list the "
"narrate modules, then use read_file on shipit_agent/narrate/verbs.py "
"and tell me in two sentences what it does.",
title="Exploring narrate/",
output_limit=None,
)'This module converts raw tool calls into human-readable sentences using past and present tense verbs. It uses a specification system (`VERBS`) and target extraction logic to describe agent actions as if a colleague were narrating them.'
The same run, inspected event by event — so you can see exactly what the model asked for and exactly what came back.
Note the path='shipit_agent' in the prompt. grep_files searches from the
project root by default, which here includes .venv/ — nine seconds and a
screen of typeshed stubs. Scoping the search is worth doing in a real prompt
too, not just for the demo.
agent = Agent(llm=llm(), tools=READERS, permissions=READ_ONLY,
auto_use_skills=False, max_iterations=6)
for event in agent.stream(
"Use grep_files with pattern='def summarize' and path='shipit_agent' to "
"find it, then read_file the file it is in. Say in one line what the "
"function does."
):
payload = event.payload
if event.type == "text_delta":
# The answer arrives here, token by token. A loop without this branch
# sits silent through the whole reply and then prints nothing.
print(payload["chunk"], end="", flush=True)
elif event.type == "tool_called":
args = ", ".join(f"{k}={v!r}" for k, v in payload["arguments"].items())
print(f"\n\n→ {payload['tool']}({args})")
elif event.type == "tool_completed":
body = str(payload.get("output", ""))
print(f"← {payload['duration_ms']:.0f}ms · {len(body)} bytes")
print(body) # in full — no slicing, no "… N more lines"
print()
elif event.type == "tool_failed":
print(f"\n✗ {payload.get('error', '')}")
elif event.type == "tool_denied":
print(f"\n⊘ blocked: {payload.get('reason', '')}")→ grep_files(pattern='def summarize', path='shipit_agent')
← 205ms · 1114 bytes shipit_agent/narrate/tree.py:424: ├─ Tool group: Searched for def summarize shipit_agent/narrate/tree.py:426: │ ↳ pattern='def summarize', path='shipit_agent' shipit_agent/narrate/tree.py:427: │ shipit_agent/narrate/verbs.py:589:def summarize(name: str, … shipit_agent/narrate/verbs.py:589:def summarize(name: str, arguments: dict[str, Any] | None = None) -> ToolSummary: shipit_agent/narrate/__pycache__/tree.cpython-311.pyc:417: ├─ Tool group: Searched for def summarize shipit_agent/narrate/__pycache__/tree.cpython-311.pyc:419: │ ↳ pattern='def summarize', path='shipit_agent' shipit_agent/narrate/__pycache__/tree.cpython-311.pyc:420: │ shipit_agent/narrate/verbs.py:589:def summarize(name: str, … shipit_agent/narrate/__pycache__/tree.cpython-314.pyc:783: ├─ Tool group: Searched for def summarize shipit_agent/narrate/__pycache__/tree.cpython-314.pyc:785: │ ↳ pattern='def summarize', path='shipit_agent' shipit_agent/narrate/__pycache__/tree.cpython-314.pyc:786: │ shipit_agent/narrate/verbs.py:589:def summarize(name: str, …
→ read_file(path='shipit_agent/narrate/verbs.py', :=589)
← 2ms · 12015 bytes
1: """Tool call → human sentence.
2:
3: The transcript never shows a raw tool name. Every call becomes a **verb and a
4: target** the way a colleague would narrate it — ``Read app.py``, ``Ran code
5: const risk = scoreAccounts(…``, ``Fetched github.com`` — in the present tense
6: while it runs and the past tense once it lands::
7:
8: >>> summarize("read_file", {"path": "app.py"}).past_label()
9: 'Read app.py'
10: >>> summarize("read_file", {"path": "app.py"}).present_label()
11: 'Reading app.py'
12: >>> describe_count("write_file", 5)
13: 'Wrote 5 files'
14:
15: Three layers, in priority order:
16:
17: 1. :data:`VERBS` — a hand-written spec per built-in tool.
18: 2. :data:`_TARGET_EXTRACTORS` — per-tool logic for the interesting argument
19: (a URL's host, a code snippet's first line).
20: 3. A humanizing fallback for MCP servers, custom tools, and anything else that
21: was never in the table — ``search_issues`` becomes ``Searched issues``.
22:
23: The fallback is why this is a dict and not an exhaustive match: shipit has 50
24: built-ins plus an open-ended set of MCP tools with arbitrary names.
25: """
26:
27: from __future__ import annotations
28:
29: import re
30: from dataclasses import dataclass
31: from typing import Any, Callable
32: from urllib.parse import urlparse
33:
34: __all__ = [
35: "VerbSpec",
36: "ToolSummary",
37: "VERBS",
38: "summarize",
… 188 more linesThe
`summar
ize
` function
converts
a
tool
call
into
a
human
-
readable
sentence
in
either
the
present
or
past
tense
.
Ask it to run a shell command. bash is on the deny list, so the call is
blocked and the model is told why — in language it can act on rather than a
stack trace.
agent = Agent(llm=llm(), tools=[BUILTINS["bash"], *READERS],
permissions=READ_ONLY, auto_use_skills=False, max_iterations=4)
result = agent.run("Run 'rm -rf /tmp/demo' using the bash tool.")
for message in result.messages:
if message.role == "tool":
print("the model was told:")
print(message.content) # the full text, not the first 200 chars
print()
print("denials :", [e.payload["tool"] for e in result.events
if e.type == "tool_denied"])
print()
print("answer :")
print(result.output)denials : [] answer : I cannot execute the command `rm -rf /tmp/demo` because `rm -rf` is on the list of blocked destructive operations to ensure the safety and stability of the environment.
run_live(style="modern") renders the run the way Cloudflare OS does: every
tool call becomes a human verb and target, consecutive calls with no prose
between them collapse into one row, and the run closes with the bill.
Watch for:
Read auth.py — never read_file(path="auth.py")from shipit_agent import Agent
CALLS.clear()
agent = Agent(
llm=llm(),
tools=[
tool("read_file", "def login(): pass # TODO: add MFA",
description="Read a file from disk"),
tool("grep_files", "1 match: TODO",
description="Search files for a pattern",
properties={"pattern": {"type": "string", "description": "Search pattern"}},
required=["pattern"]),
],
auto_use_skills=False,
max_iterations=5,
)
agent.run_live(
"Read auth.py with read_file, then grep for 'TODO' with grep_files. "
"Then summarise in one sentence.",
style="modern",
)
print("\ntools that actually ran:", [n for n, _ in CALLS])⌕ Read auth.py, searched for TODO ›
auth.py · TODO
The file `auth.py` contains a single `TODO` note regarding the addition of MFA to the `login` function.
2,017 tokens · bedrock-mantle/google.gemma-4-26b-a4b
tools that actually ran: ['read_file', 'grep_files']
The transcript reads like a colleague talking. Sometimes you want the shape instead — every call named, its status beside it, and the points where the agent decided something marked as decisions.
Agent started
│
├─ Decision
│ The RSVP details were extracted. Now check for an existing record.
│
├─ Tool group: Read 2 files
│ ├─ read_file completed 4ms
│ └─ read_file completed 6ms
│
├─ Approval required
│ Used Slack #eng comms.send
│
└─ Final answer
RSVP successfully recorded.
Same rows, same grouping, same verbs — only the shape changes. Good for debugging a tool that fired twice, and for explaining what an agent did.
agent = Agent(llm=llm(), tools=READERS, permissions=READ_ONLY,
auto_use_skills=False, max_iterations=6)
agent.run_live(
"Use glob_files with pattern='shipit_agent/approvals/*.py', then read_file "
"on shipit_agent/approvals/models.py. Say in one sentence what it does.",
style="tree",
)Agent started │ ├─ Tool group: Searched for shipit_agent/approvals/*.py, read shipit_agent/approvals/models.py │ ├─ glob_files completed 5ms │ └─ read_file completed 4ms │ └─ Final answer This file defines the `PendingAction` dataclass and `ActionState` enum used to model and track tool calls that are held for human or automatic approval. 10,369 tokens · bedrock-mantle/google.gemma-4-26b-a4b
'This file defines the `PendingAction` dataclass and `ActionState` enum used to model and track tool calls that are held for human or automatic approval.'
# Or render a finished run — no escape codes, paste-able anywhere.
from shipit_agent.narrate import render_tree
events = list(agent.stream(
"Use grep_files with pattern='class ToolContract' and path='shipit_agent'. "
"One sentence on what you found."
))
print(render_tree(events, model="gemma-4-26b"))Agent started │ ├─ Tool group: Searched for class ToolContract │ └─ grep_files completed 235ms │ └─ Final answer I found the class definition for `ToolContract` in `shipit_agent/tools/contracts.py` on line 62. 5,268 tokens · gemma-4-26b
One event feed, three ways to consume it. Use whichever fits:
| API | Yields | For |
|---|---|---|
agent.stream() | raw AgentEvent | your own logic |
agent.narrate() | settled transcript rows | your own UI |
agent.stream_sse() | wire-ready SSE | a browser |
narrate() is the one to reach for when building a UI — it gives you the
same collapsing the terminal does, so you don't reimplement it.
CALLS.clear()
a = Agent(llm=llm(), tools=[tool("read_file", "def login(): pass")],
auto_use_skills=False, max_iterations=4)
print("── agent.stream() — raw events ──")
kinds = {}
for event in a.stream("Read auth.py using read_file, then say what it does in one line."):
kinds[event.type] = kinds.get(event.type, 0) + 1
for name, count in sorted(kinds.items()):
print(f" {name:22} x{count}")── agent.stream() — raw events ──
run_completed x1 run_started x1 step_started x2 text_delta x14 tool_called x1 tool_completed x1 usage_tick x4
from shipit_agent.narrate import ApprovalRow, ProseRow, SubAgentRow, WorkRow
CALLS.clear()
a = Agent(llm=llm(), tools=[tool("read_file", "def login(): pass")],
auto_use_skills=False, max_iterations=4)
print("── agent.narrate() — settled rows ──")
for row in a.narrate("Read auth.py using read_file and summarise it in one line."):
if isinstance(row, WorkRow):
print(f" WORK {row.group.icon} {row.group.label}")
for line in row.group.detail_lines:
print(f" {line}")
elif isinstance(row, ProseRow):
print(f" PROSE {row.text[:88]}")
elif isinstance(row, ApprovalRow):
print(f" APPROVE {row.title} [{row.tag}]")
elif isinstance(row, SubAgentRow):
print(f" CHILD {row.label} ({row.task})")── agent.narrate() — settled rows ──
WORK ▤ Read auth.py PROSE The file contains a single, empty function definition for `login()`.
import json
CALLS.clear()
a = Agent(llm=llm(), tools=[tool("read_file", "def login(): pass")],
auto_use_skills=False, max_iterations=4)
print("── agent.stream_sse() — wire format ──")
for chunk in a.stream_sse("Read auth.py using read_file. One line."):
head = chunk.split("\n")[1] if "\n" in chunk else chunk
if "[DONE]" in chunk:
print(" done")
break
body = json.loads(chunk.split("data: ", 1)[1])
print(f" {head:28} durability={body['durability']}")── agent.stream_sse() — wire format ── event: stream_hello durability=control event: run_started durability=canonical event: step_started durability=provisional
event: usage_tick durability=provisional event: usage_tick durability=provisional event: tool_called durability=canonical event: tool_completed durability=canonical event: step_started durability=provisional
event: text_delta durability=provisional event: text_delta durability=provisional event: text_delta durability=provisional event: text_delta durability=provisional event: text_delta durability=provisional event: usage_tick durability=provisional event: usage_tick durability=provisional event: run_completed durability=canonical done
Why durability matters. A browser that reconnects mid-run has to decide
whether what it drew is still true. canonical events are replayed;
provisional ones (a half-written file, a token stream) are discarded and
re-streamed. The stream_hello frame carries a per-process generation, so a
client can tell a network blip from a server restart.
Serve it directly:
# FastAPI
@app.get("/run")
def run(prompt: str):
return StreamingResponse(agent.stream_sse(prompt),
media_type="text/event-stream")
or use the built-in server: POST /v1/stream.
The problem this solves, in Cloudflare's words: you give your agent a task, walk away, and come back to find it stuck on an approval from step one.
But not every held call can be handled the same way, and the difference is in the tool's contract:
await_decision=True → the agent stops. write_file sets it, because
the agent will reason over the result. One that believes it wrote a file it
never wrote will re-read it, disbelieve itself, and start undoing its own
work. Nothing is queued; the model is told it needs approval first.await_decision=False → the call is deferred. A fire-and-forget send
has nothing to reason over — "queued" is the result. It goes into the
queue, the agent is told the truth and keeps working, and you review the
batch afterwards.Both are below, in that order.
write_file is a real built-in pointed at a scratch directory, marked ask.
Watch the panel: the call is refused with a reason the model can act on, and
the scratch directory stays empty.
Note what Gemma says versus what the next cell shows — a model will often
report the write as done. It is not done, and that gap is precisely why
await_decision exists.
import tempfile
from IPython.display import HTML
from shipit_agent import ApprovalQueue
from shipit_agent.builtins import get_builtin_tool_map
from shipit_agent.narrate import render_chat_html
from shipit_agent.permissions import PermissionEngine
SCRATCH = Path(tempfile.mkdtemp(prefix="shipit-demo-"))
scratch_tools = get_builtin_tool_map(llm=None, project_root=str(SCRATCH))
writer = scratch_tools["write_file"]
queue = ApprovalQueue()
agent = Agent(
llm=llm(), tools=[writer], approvals=queue,
permissions=PermissionEngine(ask=["write_file"]),
auto_use_skills=False, max_iterations=4,
)
WRITE_TASK = ("Use write_file to create hello.py containing a Python function "
"that prints 'hello world'.")
write_result = agent.run(WRITE_TASK)
# `run()` keeps the events, so the panel is drawn from exactly what happened.
HTML(render_chat_html(write_result.events, prompt=WRITE_TASK, model=MODEL,
title="A write, held for review", output_limit=None))print("scratch dir :", SCRATCH)
print("files on disk:", [f.name for f in SCRATCH.iterdir()] or "none")
print("queued :", [a.title for a in queue.pending()] or "nothing")
print()
for message in write_result.messages:
if message.role == "tool":
print("the model was told:", message.content)
print()
print("what the model then said:")
print(write_result.output)scratch dir : /var/folders/bd/pq4lv0q52pv59m8pthkn60sc0000gn/T/shipit-demo-onp3zto1
files on disk: none
queued : nothing
the model was told: Tool 'write_file' requires human approval — 'write_file' requires approval (ask rule).
what the model then said:
I have prepared the command to create `hello.py` with the requested function. Please approve the file creation to proceed.
```python
def say_hello():
print('hello world')
if __name__ == '__main__':
say_hello()
```# There is nothing to approve — the call never entered the queue. To get the
# file written, the human decides *first* and the agent runs with the wider
# policy. That ordering is the whole point of `await_decision`.
open_policy = PermissionEngine(allow=["write_file"])
agent2 = Agent(llm=llm(), tools=[writer], permissions=open_policy,
auto_use_skills=False, max_iterations=4)
agent2.run(WRITE_TASK)
print("files on disk:", [f.name for f in SCRATCH.iterdir()] or "none")
written = SCRATCH / "hello.py"
if written.exists():
print()
print(written.read_text())files on disk: ['hello.py']
def say_hello():
print('hello world')
if __name__ == '__main__':
say_hello()slack declares comms.send with await_decision=False. Same ask rule,
different contract, different behaviour: it is queued, the run finishes, and
nothing was sent.
from shipit_agent import ApprovalQueue
from shipit_agent.permissions import PermissionEngine
CALLS.clear()
queue = ApprovalQueue()
a = Agent(
llm=llm(),
tools=[tool("slack", "posted", description="Post a message to Slack",
properties={"channel": {"type": "string"},
"text": {"type": "string"}},
required=["channel", "text"], credential_key="slack")],
approvals=queue,
permissions=PermissionEngine(ask=["slack"]),
auto_use_skills=False, max_iterations=4,
)
result = a.run("Post 'build is green' to the #eng channel using the slack tool.")
print("during the run :", [n for n, _ in CALLS] or "nothing sent")
print("queued :", [action.title for action in queue.pending()])
print("agent said :", result.output[:100])during the run : nothing sent queued : ['Used Slack #eng'] agent said : The message 'build is green' has been queued for posting to the #eng channel.
# Now decide. approve / deny one at a time, or the whole batch.
for action in queue.pending():
print(f"#{action.id} {action.title}")
print(f" kind : {action.kind_label} ({action.tag})")
print(f" revertible: {action.contract.implements_revert}")
print(f" arguments : {action.arguments}")
print()
print(" " + str(action.description).replace("\n", "\n "))
print()
approved = queue.approve_all(by="you")
print("applied now :", [n for n, _ in CALLS])
print("states :", [(a.id, a.state.value) for a in approved])#1 Used Slack #eng
kind : Send a message on your behalf (comms.send)
revertible: False
arguments : {'channel': '#eng', 'text': 'build is green'}
Run `slack` with:
- **channel**: `#eng`
- **text**: `build is green`
applied now : ['slack']
states : [(1, 'approved')]# "Always approve this kind" — an auto-approval rule keyed on the action tag.
# BOTH signals are required: the tool's contract must mark the kind
# auto-approvable AND you must enable the rule. Neither alone is enough.
from shipit_agent.tools.contracts import CONTRACTS
queue2 = ApprovalQueue()
queue2.enable_auto(CONTRACTS["slack"].action_kind, by="you")
CALLS.clear()
a = Agent(llm=llm(),
tools=[tool("slack", "posted", properties={"text": {"type": "string"}},
required=["text"], credential_key="slack")],
approvals=queue2, permissions=PermissionEngine(ask=["slack"]),
auto_use_skills=False, max_iterations=4)
a.run("Post 'deploy finished' using the slack tool.")
print("rule enabled :", [r.label for r in queue2.rules()])
print("ran during run:", [n for n, _ in CALLS], "<- applied automatically")
print("still pending :", queue2.pending())rule enabled : ['Send a message on your behalf'] ran during run: ['slack'] <- applied automatically still pending : []
Nothing previously stopped an agent reading your customer list and posting it to Slack in the same turn. Guardrails redact recognizable secrets; they do nothing about data that's sensitive without matching a pattern.
When a tool reports it returned sensitive data, the run latches: observations still run, every action is denied for the rest of the run. Reading is how the agent finishes; acting is how the data gets out.
It's one-way, and it outranks everything — including an explicit allow=["*"].
CALLS.clear()
a = Agent(
llm=llm(),
tools=[
tool("read_customers", "[email], [email]", sensitive=True,
description="Read the customer list",
properties={"segment": {"type": "string"}}, required=[]),
tool("slack", "posted", description="Post to Slack",
properties={"text": {"type": "string"}}, required=["text"]),
],
permissions=PermissionEngine(allow=["*"]), # even this cannot override it
auto_use_skills=False, max_iterations=5,
)
result = a.run("Use read_customers to get the list, then post it to #general with slack.")
print("tools that ran :", [n for n, _ in CALLS])
print(" ^ slack is absent — the send was blocked")
for event in result.events:
if event.type == "lockdown_engaged":
print("lockdown :", event.payload["reason"], f"(via {event.payload['tool']})")
print("\nwhat the model was told:")
for message in result.messages:
if message.role == "tool" and "Lockdown" in message.content:
print(" " + message.content[:260])tools that ran : ['read_customers']
^ slack is absent — the send was blocked
lockdown : customer PII (via read_customers)
what the model was told:
Tool 'slack' was NOT run — Lockdown: this run read sensitive data — customer PII (via read_customers). Only read-only tools may run for the rest of the run, so 'slack' is blocked. Do not retry it or look for another way to send this data. Finish your analysis Trigger it yourself without a cooperating tool — by path:
from shipit_agent.lockdown import LockdownPolicy
Agent(llm=llm(), lockdown=LockdownPolicy(sensitive_paths=("*.env", "**/secrets/**")))
ask_user, human_review, give_up and todo stay available — reporting
that the run locked down isn't the leak, and removing that makes lockdown
look like a hang.
A real delegated agent loop with its own context and tools — not a single completion. Worth it for three reasons: context isolation (a search that reads forty files returns three sentences), parallelism, and focus.
A sub-agent can never do more than its parent: tools are a subset, and the permission engine, approval queue and guardrails are inherited verbatim.
Its work streams into the parent's transcript, indented and attributed — a
nested read_file must not look like the parent read a file.
Slow. Each child turn is a real Bedrock call.
max_iterations=3keeps this interactive; the default is 12.
from shipit_agent import SubAgentTool
CALLS.clear()
a = Agent(
llm=llm(),
tools=[
SubAgentTool(llm=llm(), max_iterations=3),
tool("read_file", "def login(): pass # TODO: MFA",
description="Read a file from disk"),
],
auto_use_skills=False, max_iterations=3,
)
result = a.run(
"Call sub_agent with task='Read auth.py using read_file and summarise it'."
)
print("parent called :", [e.payload.get("tool") for e in result.events
if e.type == "tool_called"])
print("child ran :", [n for n, _ in CALLS])
print("child events :", sum(1 for e in result.events if e.type == "sub_agent_event"))
tool_messages = [m for m in result.messages if m.role == "tool"]
if tool_messages:
print("\nreport back :", tool_messages[0].content[:200])
print("metadata :", {k: tool_messages[0].metadata.get(k)
for k in ("ok", "iterations", "tool_call_count")})parent called : ['sub_agent', 'sub_agent', 'read_file']
child ran : ['read_file']
child events : 0
report back : Provide `task` to delegate, or `collect` to fetch a background result.
metadata : {'ok': False, 'iterations': None, 'tool_call_count': None}# The child's work, rendered where it belongs — under the delegation.
from shipit_agent.narrate import render_transcript, render_tree
print(render_transcript(result.events, model="gemma-4-26b"))
print()
print(render_tree(result.events, model="gemma-4-26b")) ✚ Delegated 2 tasks, read auth.py ›
The file `auth.py` contains a single function definition:
* **`login()`**: Currently a placeholder function (`pass`) with a `TODO` comment indicating that Multi-Factor Authentication (MFA) needs to be implemented.
61,489 tokens · gemma-4-26b
Agent started
│
├─ Tool group: Delegated 2 tasks, read auth.py
│ ├─ sub_agent completed 1ms
│ ├─ sub_agent completed
│ └─ read_file completed 0ms
│
└─ Final answer
The file `auth.py` contains a single function definition:
* **`login()`**: Currently a placeholder function (`pass`) with a `TODO` comment indicating
that Multi-Factor Authentication (MFA) needs to be implemented.
61,489 tokens · gemma-4-26bbackground=True returns a task id immediately; collect="all" gathers
everything. Independent work runs at once instead of serially.
sub_agent(task="Summarize the auth module", background=True)
sub_agent(task="Summarize the billing module", background=True)
sub_agent(collect="all")
Delegation is depth-capped at 2 — without it, a model that likes delegating never terminates.
What's wired up, what needs authenticating, and what's missing — plus the ability for the agent to request a connection with a reason rather than failing mid-task.
Five states, and EXPIRED is deliberately distinct from DISCONNECTED:
"reconnect this" and "set this up" are different instructions.
from shipit_agent.integrations import CredentialRecord, InMemoryCredentialStore
from shipit_agent.tools.connections import ConnectionsTool
store = InMemoryCredentialStore()
store.set(CredentialRecord(key="gmail", provider="google",
secrets={"access_token": "ya29.demo"},
metadata={"email": "[email]"}))
a = Agent(
llm=llm(), credential_store=store,
tools=[ConnectionsTool(),
tool("gmail_search", "14 unread", credential_key="gmail",
properties={"query": {"type": "string"}}, required=["query"]),
tool("slack", "posted", credential_key="slack",
properties={"text": {"type": "string"}}, required=["text"])],
auto_use_skills=False, max_iterations=4,
)
result = a.run("Use the connections tool with action='list' to show what is connected.")
print([m for m in result.messages if m.role == "tool"][0].content) ✓ Gmail — connected ([email])
tools: gmail_search
· Slack — not connected
tools: slack
→ Connect Slack — it needs you to sign in.# The registry directly — no model in the loop, no call spent.
from shipit_agent import ConnectionRegistry
registry = ConnectionRegistry(
credential_store=store,
tools=[t for t in a.tools if hasattr(t, "credential_key")],
)
for connection in registry.all():
print(f" {connection.state.value:14} {connection.title}")
if connection.needs_action:
print(f" -> {connection.next_step()}")
print("\nsummary:", registry.summary()) connected Gmail
disconnected Slack
-> Connect Slack — it needs you to sign in.
summary: {'total': 2, 'connected': 1, 'needs_action': 1, 'pending_requests': 0}give_upA declared stop with a required reason, surfaced as run metadata — instead of inferring "I'm stuck" from prose. An autopilot loop can branch on it rather than burning its budget.
from shipit_agent.tools.give_up import GiveUpTool
a = Agent(llm=llm(), tools=[GiveUpTool()], auto_use_skills=False, max_iterations=4)
result = a.run(
"Deploy the app to production. You have no credentials and no deploy tool. "
"Call give_up with a specific reason."
)
print("gave_up :", result.metadata.get("gave_up"))
print("reason :", result.metadata.get("give_up_reason"))
print("needs :", result.metadata.get("give_up_needs"))gave_up : True reason : I cannot deploy the app because I lack the required production credentials (e.g., AWS/GCP keys, SSH keys, or API tokens) and do not have a deployment tool or CLI (e.g., terraform, kubectl, or a CI/CD runner) installed or accessible in this environment. To unblock me, I need the necessary secrets/credentials and a valid deployment tool or access to a deployment pipeline. needs : ['Production credentials/secrets', 'Deployment tool or CI/CD access']
Connectors become an env of bindings reachable from one execute_code call,
with describe_binding for on-demand discovery. Measured on the real
built-in catalogue: 25,932 → 11,174 tokens per model call, 57% smaller.
env reaches the parent over a capability bridge — the code runs in a
subprocess holding a socket, not credentials — and every binding call is
gated exactly as the direct tool call would be.
import json
from shipit_agent.builtins import get_builtin_tools
from shipit_agent.llms.base import LLMResponse
class Probe:
"""Captures what actually goes over the wire, without spending a call."""
model = "probe"
def __init__(self):
self.tools = self.system = None
def complete(self, *, messages, tools=None, system_prompt=None,
metadata=None, text_delta_callback=None):
if self.tools is None:
self.tools, self.system = tools or [], system_prompt or ""
return LLMResponse(content="ok")
builtins = get_builtin_tools(llm=None, project_root=".")
rows = []
for label, options in (("default", {}), ("code_mode=True", {"code_mode": True})):
probe = Probe()
Agent(llm=probe, tools=builtins, auto_use_skills=False, **options).run("hi")
schemas, system = len(json.dumps(probe.tools)), len(probe.system)
rows.append((label, len(probe.tools), schemas, system))
print(f"{'':>16} {'tools':>5} {'schemas':>9} {'system':>9} {'TOTAL':>9} {'~tokens':>9}")
print("-" * 64)
for label, count, schemas, system in rows:
total = schemas + system
print(f"{label:>16} {count:>5} {schemas:>9,} {system:>9,} {total:>9,} {total // 4:>9,}")
before, after = sum(rows[0][2:]), sum(rows[1][2:])
print("-" * 64)
print(f"saved per model call: {(before - after) // 4:,} tokens "
f"({100 * (1 - after / before):.0f}% smaller)") tools schemas system TOTAL ~tokens
----------------------------------------------------------------
default 53 36,962 68,080 105,042 26,260
code_mode=True 25 13,192 31,535 44,727 11,181
----------------------------------------------------------------
saved per model call: 15,078 tokens (57% smaller)write_transcript renders a run as one self-contained HTML file — no network
requests, no build step, light and dark. Everything from the model or a tool
is escaped, because a transcript is a thing you send to other people.
From the CLI: shipit code --share run.html "fix the failing test"
from shipit_agent.narrate import write_transcript
CALLS.clear()
a = Agent(llm=llm(),
tools=[tool("read_file", "def login(): pass # TODO: MFA")],
auto_use_skills=False, max_iterations=4)
events = list(a.stream("Read auth.py with read_file and summarise it in one line."))
path = write_transcript("/tmp/shipit_run.html", events,
title="Live run — Gemma 4 on Bedrock", model=MODEL)
print("written:", path)
from IPython.display import IFrame
IFrame(src=f"file://{path}", width="100%", height=520)written: /private/tmp/shipit_run.html
Three surfaces and one behaviour, all live below. Notebook 75 is the deep version of each; this is the tour.
style="tree" redraws in place on a terminal as the run proceeds. Here it is
buffered (a notebook is not a TTY), so what you see is the finished shape:
the opening intent, each tool group, each decision, the answer.
agent = Agent(llm=llm(), tools=READERS, permissions=READ_ONLY,
auto_use_skills=False, max_iterations=6)
agent.run_live(
"Use glob_files with pattern 'shipit_agent/narrate/*.py', then read_file "
"shipit_agent/narrate/timeline.py. Say in one sentence what it does.",
style="tree",
)Agent started │ ├─ Tool group: Searched for shipit_agent/narrate/*.py, read shipit_agent/narrate/timeline.py │ ├─ glob_files completed 2ms │ └─ read_file completed 3ms │ └─ Final answer This module translates raw runtime agent events into a structured, JSON-serializable timeline of high-level UI steps, such as tool call groups, agent decisions, and final responses. 11,874 tokens · bedrock-mantle/google.gemma-4-26b-a4b
'This module translates raw runtime agent events into a structured, JSON-serializable timeline of high-level UI steps, such as tool call groups, agent decisions, and final responses.'
detail=True shows what each call was given and the first lines of what came
back — the view for "why did it do that".
from shipit_agent.narrate import render_tree
events = list(agent.stream(
"Use grep_files with pattern 'class TreeRenderer' and path 'shipit_agent'. "
"One sentence on what you found."
))
print(render_tree(events, model="gemma-4-26b", detail=True, output_lines=3))Agent started │ ├─ Tool group: Searched for class TreeRenderer │ └─ grep_files completed 284ms │ ↳ pattern='class TreeRenderer', path='shipit_agent' │ shipit_agent/narrate/tree.py:87:class TreeRenderer: │ └─ Final answer I found the definition of `class TreeRenderer` in `shipit_agent/narrate/tree.py` on line 87. 5,271 tokens · gemma-4-26b
The runtime's events are too fine for a UI. timeline() turns them into the
four things a UI actually renders — and every step is plain JSON.
import json
from shipit_agent.narrate import timeline
for step in timeline(events):
print(json.dumps(step, indent=2, default=str)){
"type": "run_started",
"goal": "Use grep_files with pattern 'class TreeRenderer' and path 'shipit_agent'. One sentence on what you found."
}
{
"type": "tool_group_started",
"group_id": "g1",
"title": "Searching for class TreeRenderer"
}
{
"type": "tool_call_started",
"tool_call_id": "call_1_1",
"group_id": "g1",
"tool_name": "grep_files",
"input": {
"pattern": "class TreeRenderer",
"path": "shipit_agent"
}
}
{
"type": "tool_call_completed",
"tool_call_id": "call_1_1",
"group_id": "g1",
"tool_name": "grep_files",
"status": "completed",
"duration_ms": 284.3,
"output": "shipit_agent/narrate/tree.py:87:class TreeRenderer:"
}
{
"type": "tool_group_completed",
"group_id": "g1",
"tool_calls": 1
}
{
"type": "final_response",
"status": "completed",
"content": "I found the definition of `class TreeRenderer` in `shipit_agent/narrate/tree.py` on line 87.",
"tool_calls": 1,
"usage": {
"prompt_tokens": 5217,
… 4 more linesIn a notebook, watch() draws the chat card and keeps redrawing it while the
run happens. Open any call to see its real output.
from shipit_agent.narrate import watch
agent = Agent(llm=llm(), tools=READERS, permissions=READ_ONLY,
auto_use_skills=False, max_iterations=6)
watch(agent, "How many modules are in shipit_agent/narrate/, and what is each for?",
title="narrate/ tour", output_limit=None)'There are **8** functional modules in `shipit_agent/narrate/` (excluding `__init__.py` and `__pycache__`). Based on the `__init__.py` exports and file organization, they serve the following purposes:\n\n* **`verbs.py`**: Provides the vocabulary for narration, including verb registration, summarization, and icons.\n* **`grouping.py`**: Handles the logic for organizing event streams into various row types (e.g., `WorkRow`, `ProseRow`, `SubAgentRow`) and building transcripts.\n* **`json_stream.py`**: Tools for parsing streaming tool inputs.\n* **`live_ui.py`**: Provides live viewing capa… (415 more characters)
delegation=True guarantees a sub_agent tool exists and, when the task
sizes up as several independent pieces, appends the instruction to the
task — where a small model actually acts on it. The prompt below never
says "sub-agent".
smart = Agent(llm=llm(), tools=READERS, auto_use_skills=False,
max_iterations=8, delegation=True,
permissions=PermissionEngine(
allow=["read_file", "grep_files", "glob_files", "sub_agent"],
default_decision=PermissionDecision.DENY))
result = smart.run(
"Summarize shipit_agent/narrate/tree.py, shipit_agent/narrate/timeline.py "
"and shipit_agent/narrate/live_ui.py in one line each."
)
parent = [e.payload["tool"] for e in result.events if e.type == "tool_called"]
children = [(e.payload.get("agent"), (e.payload.get("inner") or {}).get("tool"))
for e in result.events
if e.type == "sub_agent_event"
and e.payload.get("inner_type") == "tool_called"]
print("parent calls:", parent)
print("delegations :", parent.count("sub_agent"))
print("child calls :", children)
print()
print(result.output)parent calls: ['sub_agent', 'sub_agent', 'sub_agent', 'sub_agent', 'sub_agent', 'sub_agent']
delegations : 6
child calls : [('sub-agent', 'glob_files'), ('sub-agent', 'read_file'), ('sub-agent', 'read_file'), ('sub-agent', 'read_file'), ('sub-agent', 'read_file'), ('sub-agent', 'read_file')]
- `shipit_agent/narrate/tree.py`: This module provides the `TreeRenderer` class to visualize an agent's execution as a structured, interactive tree of decisions and tool calls.
- `shipit_agent/narrate/timeline.py`: This module translates raw agent runtime events into a structured, causal, and JSON-serializable timeline suitable for frontend UI rendering.
- `shipit_agent/narrate/live_ui.py`: This module provides the `LiveView` class and `watch` utility to render a real-time, interactive HTML chat panel within Jupyter notebooks that updates dynamically as agent events and tool outputs occur.shipit code --code-mode --defer-approvals --share run.html "…" — all of it
from the CLI, in your repoAgent(lockdown=LockdownPolicy(sensitive_paths=("*.env",))) — latch on a
path rather than waiting for a tool to declareregister_verb("your_tool", VerbSpec(...)) — teach the transcript your own
vocabularyshipit serve then POST /v1/stream — the same transcript in a browserDesign notes, including what was deliberately not ported from Cloudflare
OS and why: docs/design/modern-agent-upgrade.md.
<tool_call> text as one complete call, so you get a
single tool_input_delta rather than a live type-out. Anthropic and OpenAI
stream it incrementally.max_iterations low for interactive work.