Your Agent's Behavior Shouldn't Live in Your Codebase
Separating what an agent does from how it runs — and why the declarative pattern keeps winning

Every agent you've built probably has a system prompt buried somewhere in your application code. A string template in a TypeScript file. Maybe a multi-line constant with some variable interpolation. It works — until it doesn't.
The problem isn't the prompt itself. It's where it lives and what happens when someone needs to change it.
When your agent's behavior is defined inline — as string literals, conditional logic, and tool wiring spread across your codebase — every change to what the agent does requires a change to how the agent runs. A product manager wants to tweak the tone? That's a code change. A new tool needs to be available? Modify the handler function, update the prompt, redeploy the service. The agent's identity is entangled with your application logic, and untangling them becomes harder with every iteration.
This isn't a new problem. It's the same one infrastructure teams solved a decade ago.
The infrastructure-as-code parallel
Before Terraform and CloudFormation, infrastructure was configured through scripts, manual steps, and tribal knowledge. The shift to declarative definitions — "here's what I want, figure out how to get there" — changed everything. Not because the underlying systems got simpler, but because the interface between intent and execution got cleaner.
Agent development is at a similar inflection point. Most teams define agent behavior imperatively: write a function that constructs a prompt, wire up tool calls in a handler, manage state transitions in application code. The result is an agent whose behavior is readable only by the developer who wrote it, and editable only through a deploy cycle.
Declarative agent definitions flip this. You describe what the agent does — its inputs, tools, triggers, and execution flow — in a structured format. The runtime handles the how.
What declarative actually means here
It's not just "use YAML instead of code." Declarative agent definitions separate three concerns that most codebases collapse together:
1. Behavior definition — What the agent can do, what triggers it, what tools it has access to, how it responds. This is configuration, not code.
2. Prompt content — The actual instructions and context the agent receives. These are documents, not string literals. They should be readable and editable by anyone who understands the domain — not just developers who understand the codebase.
3. Execution logic — The tool implementations, API calls, data access patterns. This is code, and it should stay in your codebase. But it shouldn't be tangled with the agent's identity.
When these three are separated, you get something powerful: the ability to change what an agent does without touching how it runs.
What this looks like in practice
Octavus protocols are built around this separation. An agent is a folder, not a class:
support-agent/
├── protocol.yaml # Behavior definition
├── settings.json # Agent metadata
├── prompts/ # Prompt content as markdown files
│ ├── system.md
│ ├── user-message.md
│ └── shared/
│ └── company-policies.md
└── references/ # On-demand context documents
└── refund-guidelines.md
The protocol file declares the agent's structure:
input:
COMPANY_NAME: { type: string }
USER_ID: { type: string, optional: true }
triggers:
user-message:
input:
USER_MESSAGE: { type: string }
tools:
get-user-account:
description: Looking up your account
parameters:
userId: { type: string }
agent:
model: anthropic/claude-sonnet-4-5
system: system
tools: [get-user-account]
agentic: true
Prompts are markdown files with variable interpolation:
<!-- prompts/system.md -->
You are a support agent for {{COMPANY_NAME}}.
{{@shared/company-policies.md}}
Help users resolve their issues quickly and accurately.
The tool implementation lives in your backend, registered through the SDK:
const events = client.agentSessions.trigger(sessionId, 'user-message', {
input: { USER_MESSAGE: message },
tools: {
'get-user-account': async ({ userId }) => {
return await db.users.findById(userId);
},
},
});
Three layers. Three different change velocities. The protocol changes when the agent's capabilities change. Prompts change when the agent's personality or instructions change. Tool implementations change when your backend changes. None of them need to change together.
Prompts are content, not code
This is the part most teams get wrong first. A system prompt is closer to a product spec than to a function body. It describes intent, constraints, tone, and context. The people best positioned to write and iterate on prompts are often product managers, domain experts, or support leads — not the engineers who built the tool handlers.
When prompts live as string templates in TypeScript files, editing them requires developer tooling, a PR review cycle, and a deployment. When they live as markdown files in a clear directory structure, they can be versioned with git, reviewed by non-engineers, and updated independently.
Octavus takes this further with prompt interpolation — composing prompts from reusable fragments:
<!-- prompts/system.md -->
You are a customer support agent.
{{@shared/company-info.md}}
{{@shared/formatting-rules.md}}
Help users with their questions.
Shared prompt fragments across agents. Update company-info.md once, every agent that includes it picks up the change. This is the same pattern as shared partials in web templates — proven, boring, effective.
Workers: composability through declaration
The declarative pattern scales beyond single agents. Octavus workers are task-oriented agents defined with the same protocol structure but designed for composition:
# research-worker/protocol.yaml
input:
TOPIC: { type: string }
variables:
RESEARCH_DATA: { type: string }
ANALYSIS: { type: string }
tools:
web-search:
description: Search the web
parameters:
query: { type: string }
steps:
Start research:
block: start-thread
thread: research
model: anthropic/claude-sonnet-4-5
system: research-system
tools: [web-search]
maxSteps: 5
Add research request:
block: add-message
thread: research
role: user
prompt: research-prompt
input: [TOPIC]
Generate research:
block: next-message
thread: research
output: RESEARCH_DATA
Start analysis:
block: start-thread
thread: analysis
model: anthropic/claude-sonnet-4-5
system: analysis-system
Add analysis request:
block: add-message
thread: analysis
role: user
prompt: analysis-prompt
input: [RESEARCH_DATA]
Generate analysis:
block: next-message
thread: analysis
output: ANALYSIS
output: ANALYSIS
An interactive agent can call this worker — either deterministically via a run-worker block or by letting the LLM invoke it as a tool:
workers:
research-assistant:
description: Researching topic
display: stream
tools:
search: web-search
agent:
model: anthropic/claude-sonnet-4-5
system: system
workers: [research-assistant]
agentic: true
This is the composability pattern that most agent frameworks miss. Workers are reusable, testable units with clear input/output contracts. They can use different models at different steps. They can be called by multiple parent agents. And their behavior is readable by anyone who can parse a YAML file — no need to trace through callback chains or handler functions.
The version control dividend
When agent behavior is declarative and file-based, you get version control for free. Not just "the code is in git" — meaningful diffs that show exactly what changed about an agent's behavior.
A prompt update shows as a markdown diff. A new tool shows as a YAML addition. A model change is a single-line diff in the protocol. Compare that to tracing a behavioral change through imperative code where the prompt is constructed dynamically from multiple functions.
This matters for teams. It matters for auditing. And it matters at 2 AM when something changed and you need to figure out what.
The right layer to abstract
Teams building agents tend to abstract at one of two extremes. Too high: a thin wrapper around the model API that breaks the moment you need tool chaining, state management, or multi-step execution. Too low: hand-rolling session management, streaming, and tool execution from scratch for every project.
Declarative protocols sit at the right altitude. They're high enough to express complex agent behaviors without code — multi-thread workers, tool composition, conditional execution. And they're low enough that the underlying implementations (your tool handlers, your data access) remain fully under your control.
The protocol defines the shape. Your code fills it in. When the shape needs to change, you change the protocol. When the implementation needs to change, you change the code. They don't step on each other.
Start with the separation
If you take one thing from this post: look at where your agent's behavior is defined and ask whether it's tangled with your application logic. If editing a prompt requires redeploying your service, that's a sign the layers aren't separated.
You don't have to adopt a protocol system overnight. Start by moving prompts to files. Then extract tool definitions from your handler code. Then describe the execution flow declaratively. Each step makes your agents easier to understand, easier to iterate on, and easier to hand off to someone who isn't you.
The agents that survive past the prototype phase are the ones where someone other than the original author can change the behavior. Declarative definitions make that possible.



