In my previous article, I introduced an agent harness built around Pi Core, with one important design decision: a two-stage execution flow.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Main Agent
↓
Dispatch Predefined Subagent
↓
Planning Stage
│
│ No real tool access
↓
User Approval
↓
Execution Stage
│
│ Real tools become available
↓
Task Execution

The goal was straightforward:

A subagent should not gain access to real tools before the user approves what it intends to do.

Architecturally, the boundary looked clean.

Then I started using the system in real workflows.

That exposed a series of assumptions hidden behind the original design. Some were related to LLM behavior, some to MCP integration, some to runtime migration, and one turned out to be a very traditional blocking-I/O problem.

This article is about those problems, how I addressed them, and what they changed in the way I think about agent harness design.

Planning without tools — the agent couldn’t plan for capabilities it couldn’t see

Original design

To enforce the approval boundary, real tools were intentionally not loaded during the planning stage.

1
2
3
4
5
6
7
8
Planning Stage

Subagent
├── Task
├── Context
├── Planning Instructions
│
└── No Real Tools

From an access-control perspective, this worked exactly as intended.

The problem

For MCP tools, however, tool definitions carry information that is useful during planning:

  • available capabilities
  • tool descriptions
  • input semantics
  • resource boundaries
  • server-specific behavior

Once the real tools were removed from the planning context, much of this information disappeared as well.

That created a contradiction:

The agent was expected to create a realistic execution plan for capabilities it was intentionally prevented from seeing.

This became particularly noticeable when working with different MCP servers, where available capabilities and operational semantics could vary significantly.

Solution — separate tool awareness from tool access

The solution was not to weaken the approval boundary.

Real tools are still unavailable before approval.

Instead, I improved the planning prompt and context injection so that the agent can understand the relevant capabilities without receiving actual execution access.

The important distinction is:

Knowing that a capability exists is not the same as having permission to invoke it.

Lesson

Hiding execution capability should not mean hiding capability knowledge.

The approval boundary should restrict execution authority without making the planning environment blind to the environment it is planning for.

Approval solved access control, not tool complexity

Once the planning problem was addressed, another issue appeared immediately after approval.

The initial mental model was simple:

1
2
3
4
5
Approval
↓
Load Real Tools
↓
Agent Executes

Real integrations were more complicated.

The problem

The environment contains tools exposed through multiple MCP integrations.

1
2
3
4
5
6
7
            Agent
↓
┌───────────┼───────────┐
↓ ↓ ↓
MCP A MCP B MCP C
↓ ↓ ↓
Tools Tools Tools

MCP gives these integrations a common protocol boundary, but that does not automatically make every exposed tool equally convenient for an agent to reason about and operate.

As the number of MCP integrations increased, exposing everything directly started pushing too much integration complexity into the agent itself.

Solution — tool adapters and normalization

I introduced adapters between the external tool layer and the agent-facing tool model.

The purpose of these adapters is to absorb integration-specific differences instead of expecting the agent — and increasingly complicated prompts — to understand every detail of every underlying tool provider.

Lesson

Protocol interoperability is not the same thing as agent-level usability.

MCP solves an important part of tool integration. The harness still needs its own abstraction for how those capabilities should be presented to and consumed by agents.

Migrating existing Claude Code Skills exposed runtime assumptions

Before adopting Pi Core for this environment, I already had working development workflows built around Claude Code and its Skills.

Naturally, I wanted to preserve that investment.

1
2
3
4
5
Existing Claude Code Workflow
↓
Existing Skills
↓
Pi Core

The problem

During migration, I found that some behavior that looked like part of a portable Skill was actually dependent on Claude Code-specific runtime conventions.

One concrete example was the baseDir placeholder used by existing Skills.

That placeholder was not part of the Skill standard itself. It was an assumption provided by the previous runtime.

So although the Skill artifact looked portable, part of its behavior wasn’t.

The file was portable. The complete runtime behavior was not.

Solution — fix the runtime boundary

Rather than adding more prompt instructions to compensate for the difference, I modified the relevant source implementation to handle the migrated workflow correctly.

The important architectural distinction became:

1
2
3
4
5
6
7
8
9
     Skill Standard
│
Portable Behavior
│
▼
Runtime / Loader Layer
▲
│
Runtime-Specific Assumptions

The runtime layer should own runtime-specific compatibility rather than leaking those assumptions into the Skill itself.

Lesson

Portable artifacts can still carry runtime-specific assumptions.

When migrating agent workflows between runtimes, format compatibility is only part of the problem.

The more important question is:

Which behaviors actually belong to the standard, and which ones were implicitly supplied by the previous runtime?

send_conclusion — when “MUST” still doesn’t mean “always”

Another change came from the main agent lifecycle.

Original design

I had added a send_conclusion tool to the main agent, partly as the foundation for multi-line and eventually multi-destination output.

The agent was instructed to call it when its work was complete.

1
2
3
4
5
6
7
Main Agent
↓
Complete Task
↓
"Remember to call send_conclusion"
↓
send_conclusion(...)

The system prompt emphasized this requirement heavily.

The problem

It worked most of the time.

But not always.

No matter how strongly the instruction was highlighted, calling send_conclusion was still a decision made by the model.

That means there was always a non-zero probability that it would simply not happen.

For lifecycle-critical behavior:

99% compliance is fundamentally different from a guarantee.

Adding another MUST, another warning, or another paragraph to the system prompt does not change that fundamental property.

Solution — move lifecycle control into the harness

I replaced this prompt-driven behavior with text_end.

The important change wasn’t the name of the tool. It was the ownership of the behavior.

Before:

1
2
3
LLM
└── should remember
└── send_conclusion()

After, the model produces the result, and the harness guarantees what happens when that result reaches the end of its lifecycle.

Future — multi-line distribution should also be a mechanism

This also changed how I think about the original multi-line distribution goal.

It should not eventually become:

1
2
3
Prompt
↓
"Remember to send this to A, B and C"

Instead, distribution itself should become a harness-level mechanism, as in the diagram above: routing, fan-out, delivery behavior, and failure handling should not depend on the model remembering to invoke a particular tool.

Lesson

If something must happen, don’t ask the model to remember it. Make it part of the mechanism.

Prompts are excellent for expressing intent and influencing reasoning.

They are much weaker foundations for system invariants.

A blocking shell tool made multiple agents look serialized

Not every issue was agent-specific.

One of the more interesting failures was actually a conventional backend concurrency bug.

The symptom

When multiple agents were running at the same time, they sometimes appeared to block each other.

That initially looked like a problem somewhere in:

  • agent scheduling
  • orchestration
  • concurrency control
  • shared state
  • locking

The real cause was much lower in the stack.

Root cause

Shell execution inside the tool layer was not properly asynchronous for the Node.js server environment.

As a result, one agent running a shell operation could block the Node event loop.

1
2
3
4
5
6
7
8
9
Agent A
↓
Shell Tool
↓
Blocking Execution
↓
Node Event Loop
X
Agent B / Other Requests

What looked like agent-level serialization was actually runtime-level blocking.

Solution — asynchronous process execution

Shell execution was changed to use asynchronous process handling.

1
2
3
4
5
6
7
8
9
10
11
12
       Node Event Loop
/ \
/ \
Agent A Agent B
↓ ↓
Shell Task Other Work
↓
Async Process
│
│ waiting
│
└──────────────► completion

An agent can now wait for an external process without preventing unrelated agents and requests from progressing.

This was also a useful reminder for me personally. Node.js server-side development isn’t my primary backend environment, and it was easy to carry concurrency assumptions from other runtimes into this implementation.

Lesson

Agent concurrency is only as good as the concurrency model of the tools underneath it.

An agent framework doesn’t make conventional backend engineering problems disappear.

Sometimes what looks like an orchestration problem is just blocking I/O.

Without better logs, all of these problems looked similar

The previous problems happened at very different layers:

1
2
3
4
5
6
7
8
9
10
11
12
13
Planning Context
↓
Approval Boundary
↓
Tool Resolution
↓
Adapter
↓
MCP / Skill
↓
Runtime
↓
External Process

But from the outside, many of them looked almost identical:

The agent didn’t behave as expected.

That is not enough information to triage a production workflow.

Solution — logging for execution triage

I added logging specifically around the agent execution lifecycle.

The goal isn’t simply to produce more logs. It is to make an execution reconstructable.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Task
↓
Planning Context
↓
Plan
↓
Approval
↓
Tool Resolution
↓
Adapter
↓
Invocation
↓
Result
↓
text_end

When something fails, I want to be able to answer questions such as:

  • What capabilities did the agent know about while planning?
  • What exactly was approved?
  • Which tools became available afterward?
  • Which adapter processed a tool?
  • What was actually invoked?
  • How was a Skill resolved?
  • Where did the agent lifecycle terminate?
  • Was an external process still running?
  • At which boundary did the failure occur?

This makes it much easier to distinguish between LLM behavior, harness behavior, adapters, MCP integrations, Skills, runtime behavior, and external processes.

Lesson

Once you introduce architectural boundaries, you need observability across those boundaries.

Otherwise, every additional abstraction makes triage harder.

Reliability has to live somewhere

These changes initially looked unrelated:

1
2
3
4
5
6
Tool visibility       → Better planning context
MCP complexity → Adapters
Skill migration → Runtime compatibility
send_conclusion miss → text_end mechanism
Shell blocking → Async execution
Debugging → Structured triage logs

After using the system for a while, however, they started pointing toward the same engineering principle:

Be explicit about where reliability lives.

Some responsibilities are appropriate for the model:

  • reasoning
  • planning
  • generating content
  • choosing between legitimate alternatives

Other responsibilities should be guaranteed by the harness:

  • approval boundaries
  • lifecycle behavior
  • tool normalization
  • distribution
  • execution semantics
  • observability

And below both of those layers, conventional runtime engineering still matters:

  • concurrency
  • process management
  • error propagation
  • logging

The biggest lesson from this iteration wasn’t really about Pi, MCP, Claude Code, or any individual framework.

It was about the boundary between probabilistic behavior and deterministic infrastructure.

Prompts are good at expressing intent. Mechanisms are better at enforcing invariants.

The more I use agents in real workflows, the more I find myself moving critical behavior out of:

“the model should remember to do this”

and into:

“the harness guarantees this will happen.”

That, more than any individual feature, has been the direction of this iteration.