---
title: The primitives, hands on
description: "A heavy user knows the features. An agentic engineer knows which primitive to reach for: when a rule belongs in AGENTS.md, a skill or a hook, when to split work into a subagent, and when MCP beats a CLI. The examples use Claude Code because its hooks are well documented; Codex, Cursor and Gemini CLI have close equivalents of most of what follows."
author: Asem Abdo
course: Agentic Engineering
lesson: The primitives, hands on
url: https://openplanr.dev/learn/agentic-engineering/primitives
updated: 2026-10-17
related:
  - https://openplanr.dev/learn/agentic-engineering/harness-engineering.md
  - https://openplanr.dev/learn/agentic-engineering/loop-engineering.md
---

# The primitives, hands on

By [Asem Abdo](https://www.linkedin.com/in/asemabdo/) · Updated 2026-10-17

*Harness engineering, hands on*

A heavy user knows the features. An agentic engineer knows which primitive to reach for: when a rule belongs in AGENTS.md, a skill or a hook, when to split work into a subagent, and when MCP beats a CLI. The examples use Claude Code because its hooks are well documented; Codex, Cursor and Gemini CLI have close equivalents of most of what follows.

## The primitives at a glance

Level 1 · The rules

| Primitive | What it is | Reach for it when |
| --- | --- | --- |
| **CLAUDE.md** / AGENTS.md | Project instructions loaded at session start | Stable conventions, commands, architecture notes |
| **Skills** | Folders with a SKILL.md and scripts; only the description stays loaded | Repeatable procedures needed some of the time |
| **Subagents** | Agents with their own context, prompt and tools | Noisy exploration, research, parallel independent work |
| **Hooks** | Commands fired on lifecycle events | Rules that must hold every time; context injection; logging |
| **Slash commands** | Saved prompts you trigger by name | Workflows you start yourself |
| **Plugins** | Bundles of skills, hooks, commands, agents and MCP servers | Sharing one setup across projects or a team |
| **MCP servers** | External tools and data over a standard protocol | No good CLI, managed auth, SaaS integrations |
| **Headless mode** | `claude -p`, non-interactive | CI jobs, scheduled tasks, pipelines |
| **Git worktrees** | Several checkouts of one repo | Parallel agents that must not collide |
| **Plan mode** | Agent proposes a plan before editing | Unfamiliar code, risky changes |
| **Statusline** | A command that renders model, context use and cost per session | Always: you can't improve what you can't see |

> **Note:** **Commands vs skills:** commands are for things **you** start; skills are for things the **agent** should recognize and pull in on its own.

## Hooks: the only guarantee

Level 1 · The rules

Everything else in the harness is advice the model weighs. A hook is code that runs every time its event fires. It receives the event as JSON on stdin and answers with an exit code.

**Hook exit codes**

1. **0**: success. The action proceeds. For most events stdout is not shown to the model.
2. **2**: blocking error. For PreToolUse the tool call is blocked and stderr is sent to Claude as the reason.
3. **Anything else**, including 1: a non-blocking error. The action still proceeds.

```python check=run title="How Claude Code treats a PreToolUse hook's exit code"
def after_pre_tool_use_hook(exit_code, stderr):
    if exit_code == 0:
        return "tool runs"
    if exit_code == 2:
        return "tool blocked, Claude reads: " + stderr
    return "tool runs anyway (non-blocking hook error)"

for code in (0, 2, 1):
    print(
        code,
        "->",
        after_pre_tool_use_hook(code, "Migrations are generated, never edited"),
    )
```

```text title="Output (Python 3.14.8)"
0 -> tool runs
2 -> tool blocked, Claude reads: Migrations are generated, never edited
1 -> tool runs anyway (non-blocking hook error)
```

> **Watch out:** **The classic trap:** a guardrail hook that exits 1. It looks like a failure, so people assume it blocks. It does not. Guardrails exit 2.

The hook logic itself, written as a testable function. The payload has the same shape Claude Code sends on stdin for an Edit or Write call:

```python check=run title="A migration guard, tested with real payload shapes"
import json

def migration_guard(payload):
    path = payload.get("tool_input", {}).get("file_path", "")
    if "/migrations/" in path:
        return (
            2,
            "Migrations must be generated with prisma migrate, never edited by hand",
        )
    return 0, ""

edit_migration = json.loads(
    '{"tool_name": "Edit", "tool_input": {"file_path":'
    ' "prisma/migrations/20261012_init/migration.sql"}}'
)
edit_service = json.loads(
    '{"tool_name": "Edit", "tool_input": {"file_path": "src/users/users.service.ts"}}'
)
print(migration_guard(edit_migration))
print(migration_guard(edit_service))
```

```text title="Output (Python 3.14.8)"
(2, 'Migrations must be generated with prisma migrate, never edited by hand')
(0, '')
```

Wired up in `.claude/settings.json` (configuration, so not executed here):

```json check=skip title="settings.json hook registration (configuration file, not executable)"
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": "python3 .claude/hooks/migration_guard.py" }
        ]
      }
    ]
  }
}
```

- **SessionStart:** Inject context or set environment at the start, resume or after compaction.
- **UserPromptSubmit:** Inspect the prompt; add just-in-time context when it matches an intent.
- **PreToolUse:** Gate: block, allow or modify a tool call before it runs.
- **PostToolUse:** Side effects after a tool: format, lint, log, feed back problems.
- **Stop / SubagentStop:** Decide whether the agent may finish, for example "tests must pass first".
- **PreCompact:** Save what matters before the conversation is summarized.

## A Stop hook that can loop forever

Level 2 · Why it works

A Stop hook that exits 2 forces Claude to keep working. If its condition never becomes true, the agent never stops. Claude Code passes `stop_hook_active` so your hook can tell it is already continuing because of itself.

```python check=err:RecursionError title="A Stop hook with no escape hatch"
def stop_hook(payload):
    tests_pass = False
    return 2 if not tests_pass else 0

def finish(turn=0):
    if stop_hook({"stop_hook_active": turn > 0}) == 2:
        return finish(turn + 1)
    return "done"

finish()
```

```text title="Traceback (Python 3.14.8)"
Traceback (most recent call last):
  File "main.py", line 10, in <module>
    finish()
    ~~~~~~^^
  File "main.py", line 7, in finish
    return finish(turn + 1)
  File "main.py", line 7, in finish
    return finish(turn + 1)
  File "main.py", line 7, in finish
    return finish(turn + 1)
  [Previous line repeated 995 more times]
  File "main.py", line 6, in finish
    if stop_hook({"stop_hook_active": turn > 0}) == 2:
       ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
RecursionError: maximum recursion depth exceeded
```

```python check=run title="The fix: respect stop_hook_active"
def stop_hook(payload):
    tests_pass = False
    if payload["stop_hook_active"]:
        return 0
    return 2 if not tests_pass else 0

def finish(turn=0):
    if stop_hook({"stop_hook_active": turn > 0}) == 2:
        return finish(turn + 1)
    return f"done after {turn + 1} attempts"

print(finish())
```

```text title="Output (Python 3.14.8)"
done after 2 attempts
```

## Subagents and the brief

Level 2 · Why it works

A subagent starts with a clean context. It knows only what the parent writes in the brief. That is its strength (no noise) and its weakness (no implicit knowledge).

```python check=run title="What survives a handoff"
parent_knows = {
    "task",
    "repo layout",
    "never edit migrations",
    "use pnpm",
    "tests must pass",
}
brief = {"task", "repo layout", "tests must pass"}
lost = sorted(parent_knows - brief)
print("subagent never hears:", lost)
```

```text title="Output (Python 3.14.8)"
subagent never hears: ['never edit migrations', 'use pnpm']
```

**Say it:** "The main agent writes the brief and reviews the result; the subagent does the noisy work in a clean context. Most subagent failures I see are brief failures: a constraint the parent knew but never wrote down."

## CLI or MCP, and running many agents

Level 3 · Mastery

- "The agent has a shell and git, gh, kubectl exist." → **CLI**: The model already knows them, output pipes, almost no context cost.

- "No shell (claude.ai, a desktop app) or OAuth is needed." → **MCP**: Managed auth, typed tools, works in any MCP client.

- "Twenty MCP tools loaded, three used." → **Trim or defer**: Every tool definition costs context on every turn.

| Parallel work need | Tool |
| --- | --- |
| Several agents on one repo without collisions | One git worktree per agent |
| Agents that survive closing the terminal | A terminal multiplexer such as tmux, or the agent's own background mode |
| Agents in CI | Headless `claude -p` with tight permissions and structured output |

## Predict the output

### Progressive disclosure cost

Level 2.

```python
descriptions = [40, 35, 45, 50]
bodies = {"release": 2_400, "migrations": 1_800}
print(sum(descriptions), sum(descriptions) + bodies["migrations"])
```

1. 170 1970
2. 170 4370
3. 4370 4370
4. 1970 1970

**Answer:** option 1.

```text title="Output (Python 3.14.8)"
170 1970
```

**Why:** Only the four descriptions (170 tokens) are always loaded. A task that needs the migrations skill adds just that body: 1,970 total.

## Check your understanding

Levels 1 to 3.

**Q:** A PreToolUse hook exits 1. Does the tool call run?

**A:** Yes. Only exit code 2 blocks. Any other non-zero code is a non-blocking error.

**Q:** When is a skill better than a CLAUDE.md rule?

**A:** When the content is a long procedure needed only for some tasks. The description stays loaded; the body loads on demand, so it costs nothing until it matters.

**Q:** Why does a Stop hook need to check stop_hook_active?

**A:** Without it, a hook whose condition never becomes true forces Claude to continue forever.
