Writing an Agent from scratch
Build a multi-stage Agent that fetches a URL, extracts bullets, and emits a typed artifact.
This guide walks you through writing a real Agent — deeper than Quickstart’s echo template. You’ll build research-summarizer: it takes a URL, fetches the page, calls an upstream LLM to extract 5–10 bullet points, and emits a summary.v1 artifact.
Five minutes if you copy-paste; 30 if you also internalize the why for each piece.
What you’ll learn
- How to wire up a
tools.fetchcapability against a narrowegressallowlist. - How to compose two prompt stages (extract + summarize) so the LLM sees a clean second-pass prompt.
- How to type the output artifact and validate it.
- How to write fixtures so
pack lintkeeps you honest.
1. Scaffold
soyaos pack init research-summarizer --template chat
cd research-summarizer
The chat template gives you a single-stage Agent that calls an upstream LLM. We’ll grow it into two stages and add a tool.
2. The manifest
Replace the generated soyapack.yaml with:
apiVersion: soyaos.ai/v0
kind: Agent
name: research-summarizer
version: 0.1.0
virtual_model_id: soya:research-summarizer
description: Fetch a URL and produce 5–10 bullet points of the main argument.
owner: chzealot
license: MIT
capabilities:
egress:
- host: api.openai.com
port: 443
protocol: https
fs:
read:
- /workdir
write:
- /workdir/out
determinism_tier: read-only
inputs:
type: object
required: [url]
properties:
url: { type: string, format: uri }
max_bullets: { type: integer, default: 8, minimum: 3, maximum: 12 }
outputs:
summary: summary.v1
prompts:
extract: prompts/extract.md
summarize: prompts/summarize.md
tools:
fetch:
kind: builtin.http_get
params: { timeout: 15s, max_bytes: 2_000_000 }
upstream:
prefer: openai
examples:
short_blog:
input: { url: "https://example.com/post", max_bullets: 5 }
Tool egress is the easy mistake.
builtin.http_gethonors the pack’segressallowlist — if you want to fetch fromexample.comyou need to add it too. We’ve kept this manifest pinned to a single host so the example stays clean; in a real Agent you’d either add the destinations explicitly or proxy through the Moon.
3. The prompts
prompts/extract.md — pulls clean text out of the raw fetched HTML:
You receive a single HTML document in `{{ fetched_html }}`.
Return ONLY the article body as plain text. Strip nav, header, footer,
ads, comments, and "related articles" widgets. Preserve paragraph breaks
as blank lines.
If the page is clearly not an article (login page, search result, 404),
emit exactly: `<<NON_ARTICLE>>` and nothing else.
prompts/summarize.md — takes the clean text and produces bullets:
You are summarizing an article into {{ max_bullets }} bullet points.
# Article
{{ article_text }}
# Rules
- One claim per bullet. No padding.
- Lead with the strongest claim.
- If the article has a counter-thesis, include it as the last bullet
prefixed with "Counter:".
- Output as a JSON array of strings. No markdown, no preamble.
4. The stage wiring
A new pipeline.yaml defines how the stages connect:
# pipeline.yaml
stages:
- name: extract
when: always
inputs:
fetched_html: '{{ tools.fetch(url=inputs.url).body }}'
on: prompts.extract
output: article_text
- name: summarize
when: 'article_text != "<<NON_ARTICLE>>"'
inputs:
article_text: '{{ stages.extract.output }}'
max_bullets: '{{ inputs.max_bullets }}'
on: prompts.summarize
output: bullets
parse_as: 'json:string[]'
outputs:
summary:
bullets: '{{ stages.summarize.output }}'
source_url: '{{ inputs.url }}'
Notice:
tools.fetch(...)is evaluated against the manifest’stools:block. The Comet checks the egress allowlist at call time.when:lets us skip the second stage if extraction returned the sentinel — saves an LLM call.parse_as: 'json:string[]'validates the LLM output is a JSON array of strings; a parse failure exits with code2and aerrorScope event.
5. The example fixture
examples/short_blog.json:
{
"url": "https://example.com/post",
"max_bullets": 5,
"_expected_kind": "article"
}
_expected_kind is metadata for pack lint — it doesn’t reach the Agent, but it lets lint warn you if extract returns <<NON_ARTICLE>> for an input you claimed should be an article.
6. Validate, lint, run
soyaos pack validate .
soyaos pack lint . --strict
soyaos run . --input @examples/short_blog.json
If everything’s wired right:
▶ research-summarizer @0.1.0 · 2 stages · capabilities: egress[1]
extract ████████████ 2.1s
summarize ████████████ 3.4s
✓ run_018f3a · ok in 5.51s
{
"summary": {
"bullets": [
"Distributed compaction beats vertical scaling once your write-heavy table crosses ~50M rows.",
"Bloom filter false-positive rate compounds across levels — tune at level 0, not the leaf.",
"Counter: tiered compaction wastes 2-3× disk during peak ingest."
],
"source_url": "https://example.com/post"
}
}
7. Iterate quickly
soyaos run --watch . re-runs the pack whenever you touch a prompt file. Combine with --json | jq to live-watch the Scope events:
soyaos --json run . --input @examples/short_blog.json --watch | \
jq -c 'select(.kind == "llm_response") | {tokens: .completion_tokens, stage}'
8. Ship it
soyaos pack push . --moon https://stage.example.com
Then point a client at stage.example.com/v1 and use model: soya:research-summarizer. When you’re happy, mirror to prod:
soyaos pull --moon https://stage.example.com soya:[email protected]
soyaos push --moon https://prod.example.com soya:[email protected]
Where to read more
- Capabilities & sandbox — what
egress,fs, anddeterminism_tieractually enforce. - Virtual models & BYOK — how the
upstream:block resolves. - SoyaPack v0 manifest — every field.
- Sign and publish a SoyaPack — once you’re ready for prod.