Implementation notes
The engineering behind how Jack is built. Each note is a decision that had a reasonable alternative, and the reason that alternative lost.
The tools are undecorated
A tool that declares a Context parameter stays a plain function. @function_tool puts every
parameter into the schema the model sees, and the model has no DocsCorpus to pass. build()
compiles the function instead, so the model is offered only query or slug.
The context type is declared
deck = Deck(agents=[jack], context=DocsCorpus)context=DocsCorpus is the type; the instance goes in per run. Declaring it makes build()
check every Context[...] in the catalog, tools and the instructions callable alike, before a
question is ever asked. The wrong type raises ContextTypeError naming both, at startup rather
than mid-answer.
Search is a dict and a scan
The corpus is about thirty pages and 120 KB, so retrieval is TF-IDF over Path.read_text(), in
roughly thirty lines.
A vector store lost on three counts: at this size a scan beats an embedding round-trip, there is
no index to rebuild when a page changes, and it cannot return a stale chunk. DocsCorpus.search
is where that decision lives, and its signature is what stays fixed if the corpus outgrows it.
Explicit composition, not Deck.from_project()
Every other example discovers its catalog from a .agentdeck/ directory. Jack cannot, because
his tools and his Deck share one DocsCorpus class and a bundle has no clean way to share a type
with the program composing it:
- a module beside
.agentdeck/imports only when the process started in that directory, and a server starts wherever its supervisor puts it; - a module inside it resolves through
agentdeck_project, an internal alias that exists only afterfrom_project()has run, which is too late for a module-level import.
Explicit composition has neither problem. Both front doors are described in Deck.
Its own route, not Deck.asgi()
AgentDeck packages an HTTP surface and Jack writes his own, for two structural reasons:
| Context cannot cross HTTP | A run started through asgi() carries context=None. There is no wire form for a live Python object, and both tools need the DocsCorpus. |
| The chat wire is frozen | Its body is exactly {"session_id", "message"}, pinned byte-for-byte by tests/golden/. The page a reader is on has nowhere to go in it. |
The result is about forty lines over deck.stream(), streaming canonical events with no
translation layer.
What a public endpoint needed
Jack is unauthenticated on purpose: a documentation assistant that asks you to log in is not a documentation assistant. The limits below are the whole of what stands between a public hostname and someone else’s bill.
- An event allowlist. Five kinds reach the browser.
tool.call.completedis not among them: itsresult_previewis the tool’s output verbatim, so a tool that raised would put its exception text on a public wire. - A quota shaped as conversations. Three sessions per client per day, twenty turns each. Turns bound a conversation that re-sends its history every turn; sessions stop the way around that, which is to finish twenty turns and start again.
- A token ceiling. The only structural answer to “can this be used to write someone’s essay”. An instruction is persuadable; a ceiling is not.
- An origin check, which is not authentication.
Originis browser-set and forged in one flag. It stops another site embedding the endpoint, and nothing more.
Prompt injection
The structure of the prompt cannot be forged: the page slug is validated against the corpus, so only known values survive, and the delimiter is stripped from any reader selection. What the model chooses to say with that text is not guarded, and no delimiter makes it so.
The full accounting is in the example’s README .