IBM Bob added lifecycle hooks and I was super curious to try out why and how it works. With some simple settings in .bob/settings.json you can now decide whether Bob is allowed to write a file before the actual write happens. I can add a directory rule in AGENTS.md and hope for the best outcome but I prefer an exit code for the few paths that must stay untouched. I wrote about what I called deterministic islands before. Now we can implement this with IBM Bob.
Instructions such as “only edit app/“ help the model but they are not guaranteed. A lifecycle hook adds a shell command outside the model. It sees the tool request, applies a deterministic rule, and returns an exit code. The model can misunderstand a sentence but with this, the agent now gets a binding hook to the operating system with exit code 2.
IBM Bob 2.0.2 added command lifecycle hooks earlier this month. The first release supports five events: session start, prompt submission, before and after a tool call, and agent stop. That is enough for a simpler loop I want to show you here. We will add current repository context, block matched writes, run checks after edits, and save evidence when Bob finishes.
Two events can stop work completely: UserPromptSubmit and PreToolUse. PostToolUse and Stop do not block or stop the agent. The hooks control a few clear points; CI should still own the final decision.
Niklas Heidloff’s watsonx Orchestrate example is a good starting point too. His Bob skill contains the schemas for agent YAML and Python tools. A PreToolUse hook runs deterministic validators when Bob edits those files. When a rename leaves out spec_version, the validator catches the invalid YAML before deployment and Bob can repair it. I use the same idea for a more general repository boundary, fast verification, and a final report.
What We Are Building
This tutorial uses a small release-policy project. Its 13 unit tests already pass. A separate acceptance suite defines the missing behavior: when the current version is out of support, recommend the supported version with the longest remaining lifetime.
Bob gets this job through a project command and a skill. I wrap this with all five hooks:
SessionStartadds the allowed paths, test commands, and previous report to Bob’s contextUserPromptSubmitadds the current Git state and last automatic verification to the next promptPreToolUseblocks Bob’s four native edit tools outsideapp/,tests/, andREADME.mdPostToolUseruns both test suites after a matched edit and records the resultStopruns verification again and writes a Markdown report after Bob finishes
I also included a read-only verifier persona and migration examples for Codex and Claude Code:
bob-lifecycle-hooks/
├── demo/
│ ├── AGENTS.md
│ ├── app/
│ │ └── release_policy.py
│ ├── tests/
│ ├── acceptance/
│ └── .bob/
│ ├── settings.json
│ ├── hooks/
│ ├── commands/upgrade-plan.md
│ ├── skills/safe-release-change/SKILL.md
│ └── agents/verification-reader.md
├── migration/
│ ├── bob-settings.json
│ ├── codex-hooks.json
│ └── claude-hooks.json
└── solution/
The scripts accept three payload variants: IBM’s documented shape, the shape from my installed 2.0.2 runtime, and the matching Codex and Claude fields. This lets us keep one policy core and use small configuration files for each host. I have to admit that the official documentation could use some improvements and I have filed some issues for the team.
What You Need
I use a POSIX shell for the commands below. On Windows, Bob runs hook commands through cmd /c. Replace python3 with py -3 in .bob/settings.json if that is how Python is installed on your machine.
IBM Bob IDE 2.0.2 or newer, or BobShell 2.0.1 or newer
Advanced mode with Skills enabled
A Bob API key when you use the BobShell path
Python 3.11 or newer
Git
About two ☕️☕️
The Bob skills documentation says that skills are available in Advanced mode only. Bob asks before it activates a skill unless you enable auto-approval.
Create a Clean Lab Workspace
First, clone the article repository and copy the baseline into a separate workspace. Then give Git a clean starting point:
git clone https://github.com/myfear/the-main-thread.git
cp -R the-main-thread/bob-lifecycle-hooks/demo bob-hooks-lab
cd bob-hooks-lab
git init
git add .
git commit -m "Create Bob lifecycle hooks baseline"
Open bob-hooks-lab as the workspace in IBM Bob. Bob 2.0.2 also introduced workspace trust. Before you trust this folder, read .bob/settings.json and every script under .bob/hooks/. The lifecycle-hooks documentation says that hooks run with your full user permissions.
Run the unit-test baseline:
python3 -m unittest discover -s tests -vExpected ending:
Ran 13 tests
OKThese 13 tests cover the application, both Bob payload shapes, the edit-tool matcher, and the hook scripts. Now run the acceptance suite:
python3 -m unittest discover -s acceptance -vIt should fail with this error:
ImportError: cannot import name 'recommended_upgrade' from 'app.release_policy'
FAILED (errors=1)This is the main feature of the demo. acceptance/ contains the fixed requirement. Bob may read those files, but the edit hook will stop Bob’s native editing tools from changing them.
Five Hooks, Two Blocking Points
Bob merges global hooks from ~/.bob/settings/settings.json with project hooks from .bob/settings.json. Global hooks always run. Project hooks only apply to the current workspace, so they are the ideal base for a repository policy.
Every hook receives one JSON object on standard input, and its command runs from the task working directory. Bob uses a 10-second timeout by default. I always set explicit timeouts for the hooks so we do not see any unexpected results.
These are the five event contracts:
SessionStart - Runs once before the first turn. Standard output becomes model context. It cannot block the session.
UserPromptSubmit - Runs before each prompt reaches the model. Standard output becomes context alongside the prompt. Exit code 2 blocks the prompt.
PreToolUse - Runs before a matched tool. The matcher is a regular expression against the tool name. Standard output is ignored. Exit code 2 blocks the tool and Bob continues the session.
PostToolUse - Runs after a matched tool finishes. Standard output is ignored, and exit code 2 has no blocking effect because the tool has already run. IBM’s page says this includes failed tools; the stable 2.0.2 runtime I inspected skips the hook when the tool result is marked as an error.
Stop - Runs after the agent’s final turn. Standard output is ignored, and exit code 2 cannot reopen the turn.
For this example, PreToolUse provides the hard write boundary. The later hooks collect evidence. If we need another turn, UserPromptSubmit can add that evidence to Bob’s next prompt.
Turn a Skill into an Executable Check
Niklas’s open-source example matches four Bob tools: write_file, apply_diff, search_and_replace, and insert_content. The hook sends changed files to validators for watsonx Orchestrate agents, connections, knowledge bases, tools, and flows. The skill teaches Bob the domain and the scripts give it a clear yes or no result.
I kinda like this split because it works for many projects. A Java skill can explain extension conventions while a hook runs the formatter. A security skill can describe the threat model while a hook rejects a committed secret. The model still handles judgment and repair. The hook handles the small decision that must be predictable and deterministic.
Niklas’s implementation uses the presence of .bob/skills/watsonx-orchestrate/SKILL.md as a signal that the skill is active. This works as a project-level switch, but it does not prove that Bob loaded the skill for the current turn. If your repository has several optional domains, use an explicit project marker or separate hook configuration. Bob does not include active skill state in the hook payload.
Configure the Lifecycle
Here is the complete .bob/settings.json:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "python3 .bob/hooks/session_start.py",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 .bob/hooks/prompt_context.py",
"timeout": 5
}
]
}
],
"PreToolUse": [
{
"matcher": "^(write_file|apply_diff|search_and_replace|insert_content)$",
"hooks": [
{
"type": "command",
"command": "python3 .bob/hooks/guard_write.py",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "^(write_file|apply_diff|search_and_replace|insert_content)$",
"hooks": [
{
"type": "command",
"command": "python3 .bob/hooks/post_write.py",
"timeout": 30
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python3 .bob/hooks/stop_report.py",
"timeout": 30
}
]
}
]
}
}The nesting is event -> matcher group -> command handlers. In the current Bob release, matcher only applies to PreToolUse and PostToolUse. When you leave it out, every tool matches.
I took the four tool names from Niklas’s example and checked them against the edit tools packaged with IBM Bob 2.0.2. The ^ and $ anchors prevent a future tool with a partly matching name from entering this policy by accident. This narrow boundary leaves execute_command, MCP filesystem tools, IDE extensions, and future edit tools with other names outside the check.
Normalize the Hook Payload
When Bob runs a hook, it starts the configured command as a separate process and writes one JSON object to its standard input, the same input stream a command reads from a pipe. That object is the hook payload. It tells the script which lifecycle event fired. For a tool hook, it also contains the tool name and the arguments Bob wants to pass to that tool.
Our write guard needs one value from those arguments: the target file path. A first version could read it directly from input.path. That works with the payload in IBM’s lifecycle page:
{
"event": "PreToolUse",
"tool": "write_file",
"input": {
"path": "app/release_policy.py"
}
}The stable IBM Bob 2.0.2 app on my machine sends the same information with different field names:
{
"hook_event_name": "PreToolUse",
"tool_name": "write_file",
"tool_input": {
"path": "app/release_policy.py"
}
}Niklas’s working example uses this second version too. The packaged runtime also adds cwd, tool_use_id, the session source, and the last assistant message where they apply.
Both payloads describe the same write request. A script that only reads input.path will miss the path in the second payload. Our guard fails closed, so it would block every edit. A permissive guard could make the worse mistake and allow a write it never checked.
Normalization keeps that format problem away from the policy. In this article, normalization means reading several external field names and returning one internal value. tool_input() accepts either input or tool_input. tool_name() accepts either tool or tool_name. The guard can then ask for a path without knowing which Bob payload supplied it.
I keep those mappings in one adapter because the same issue appears when we move hooks between Bob, Codex, and Claude. Each host has its own event names, tool names, and argument fields. The directory policy should not care about this formatting.
When you adopt a new Bob release, capture one payload locally and inspect it. Keep secrets out of the log. Then update the adapter once if the format changed. The policy scripts can stay as they are.
For this example, .bob/hooks/hooklib.py owns input handling, path checks, Git inspection, and verification:
from __future__ import annotations
import json
import os
import re
import shlex
import subprocess
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
ROOT = Path.cwd().resolve()
STATE_DIR = Path(os.environ.get("BOB_HOOK_STATE_DIR", ROOT / ".bob" / "state"))
PATCH_PATH = re.compile(r"^\*\*\* (?:Add|Update|Delete) File: (.+)$", re.MULTILINE)
def read_payload() -> dict[str, Any]:
try:
payload = json.load(sys.stdin)
except json.JSONDecodeError as exc:
raise ValueError(f"Hook input is not valid JSON: {exc.msg}") from exc
if not isinstance(payload, dict):
raise ValueError("Hook input must be a JSON object")
return payload
def event_name(payload: dict[str, Any]) -> str:
value = payload.get("event", payload.get("hook_event_name", ""))
return value if isinstance(value, str) else ""
def tool_name(payload: dict[str, Any]) -> str:
value = payload.get("tool", payload.get("tool_name", ""))
return value if isinstance(value, str) else ""
def tool_input(payload: dict[str, Any]) -> dict[str, Any]:
value = payload.get("input", payload.get("tool_input", {}))
return value if isinstance(value, dict) else {}
def paths_from_payload(payload: dict[str, Any]) -> list[str]:
value = tool_input(payload)
paths: list[str] = []
for key in ("path", "file_path"):
candidate = value.get(key)
if isinstance(candidate, str) and candidate.strip():
paths.append(candidate.strip())
command = value.get("command")
if isinstance(command, str):
paths.extend(match.strip() for match in PATCH_PATH.findall(command))
return list(dict.fromkeys(paths))
def path_is_allowed(raw_path: str) -> bool:
candidate = Path(raw_path)
resolved = candidate.resolve() if candidate.is_absolute() else (ROOT / candidate).resolve()
try:
relative = resolved.relative_to(ROOT)
except ValueError:
return False
allowed = os.environ.get("BOB_HOOK_ALLOWED_PATHS", "app,tests,README.md")
for entry in (item.strip() for item in allowed.split(",")):
if not entry:
continue
allowed_path = Path(entry)
if relative == allowed_path or allowed_path in relative.parents:
return True
return False
def git_output(*args: str) -> str:
completed = subprocess.run(
["git", *args],
cwd=ROOT,
text=True,
capture_output=True,
check=False,
timeout=5,
)
return completed.stdout.strip() if completed.returncode == 0 else "unavailable"
def verification_commands() -> list[list[str]]:
override = os.environ.get("BOB_HOOK_VERIFY_COMMAND")
if override:
return [shlex.split(override)]
return [
[sys.executable, "-m", "unittest", "discover", "-s", "tests", "-v"],
[sys.executable, "-m", "unittest", "discover", "-s", "acceptance", "-v"],
]
def run_verification() -> tuple[bool, str]:
sections: list[str] = []
passed = True
for command in verification_commands():
try:
completed = subprocess.run(
command,
cwd=ROOT,
text=True,
capture_output=True,
check=False,
timeout=12,
)
passed = passed and completed.returncode == 0
output = "\n".join(
part.strip() for part in (completed.stdout, completed.stderr) if part.strip()
)
sections.append(
f"$ {shlex.join(command)}\nexit={completed.returncode}\n{output}".rstrip()
)
except (OSError, subprocess.TimeoutExpired) as exc:
passed = False
sections.append(f"$ {shlex.join(command)}\nerror={exc}")
report = "\n\n".join(sections)
return passed, report[-12000:]
def write_state(filename: str, content: str) -> Path:
STATE_DIR.mkdir(parents=True, exist_ok=True)
target = STATE_DIR / filename
temporary = STATE_DIR / f".{filename}.tmp"
temporary.write_text(content, encoding="utf-8")
temporary.replace(target)
return target
def timestamp() -> str:
return datetime.now(UTC).replace(microsecond=0).isoformat()The path check resolves the requested target before it compares directories. This stops the simple ../escape.txt case and follows existing symlinks before it decides. Invalid JSON and missing paths fail closed because an edit guard should never guess.
paths_from_payload() reads Bob’s path, Claude’s file_path, and the file headers from Codex apply_patch calls. It also accepts both top-level Bob shapes. This small adapter can move between hosts; the tool names and hook configuration still belong to each host.
Block Writes to the Control Files
With the payload handling in hooklib.py, the PreToolUse handler stays small:
#!/usr/bin/env python3
from __future__ import annotations
import sys
from hooklib import path_is_allowed, paths_from_payload, read_payload
def main() -> int:
try:
payload = read_payload()
paths = paths_from_payload(payload)
except ValueError as exc:
print(f"Blocked write: {exc}", file=sys.stderr)
return 2
if not paths:
print("Blocked write: the hook payload contained no file path", file=sys.stderr)
return 2
blocked = [path for path in paths if not path_is_allowed(path)]
if blocked:
print(
"Blocked write outside app/, tests/, and README.md: " + ", ".join(blocked),
file=sys.stderr,
)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())The handler writes the reason to standard error because Bob ignores standard output for PreToolUse. Exit code 2 stops the matched tool call. Bob reports the block and keeps the session open, so it can choose another file or explain the conflict.
This protects acceptance/, .bob/, AGENTS.md, and .gitignore from Bob’s four native edit tools. A shell command can still change those files. Keep Bob’s normal approval policy enabled, and add an operating-system sandbox when your threat model needs a stronger boundary.
Add Live Context at the Start of a Session
Static conventions belong in AGENTS.md. Bob loads a workspace AGENTS.md by default, so I put durable rules such as “acceptance tests are read-only” there.
Facts that change between runs belong in SessionStart. This handler prints the current branch, the test commands, and the end of the previous report:
#!/usr/bin/env python3
from __future__ import annotations
from hooklib import STATE_DIR, git_output, read_payload
def main() -> int:
read_payload()
last_report = STATE_DIR / "final-report.md"
previous = (
last_report.read_text(encoding="utf-8")[-2000:]
if last_report.exists()
else "No previous hook report exists."
)
print("Release Policy Lab context")
print(f"Git branch: {git_output('branch', '--show-current')}")
print("Allowed writes: app/, tests/, README.md")
print("Protected acceptance criteria: acceptance/")
print("Verification: python3 -m unittest discover -s tests -v")
print("Acceptance: python3 -m unittest discover -s acceptance -v")
print("Previous report:")
print(previous)
return 0
if __name__ == "__main__":
raise SystemExit(main())Keep this output short because every printed character consumes model context. Store full test logs, dependency trees, and Git diffs in files that Bob can read when it needs them.
The next-turn hook follows the same rule. It adds the changed filenames and up to 3,000 characters from the last automatic verification:
#!/usr/bin/env python3
from __future__ import annotations
from hooklib import STATE_DIR, git_output, read_payload
def main() -> int:
read_payload()
last_verification = STATE_DIR / "last-verification.txt"
result = (
last_verification.read_text(encoding="utf-8")[-3000:]
if last_verification.exists()
else "No verification hook has run yet."
)
print("Current workspace evidence")
print(f"Changed files:\n{git_output('status', '--short')}")
print("Last automatic verification:")
print(result)
return 0
if __name__ == "__main__":
raise SystemExit(main())This is how I return verification evidence to the model. Bob 2.0.2 cannot inject it directly from PostToolUse, so UserPromptSubmit adds it on the next turn.
Record Verification After Every Write
Running a complete test suite after every small edit gets expensive in a real Java repository. This lab is tiny, and both suites finish in less than a second. In a larger project, I would run a formatter or one focused test after each edit. The full build can wait for Stop or CI.
post_write.py reads the event payload, runs both commands, and stores a report whose command output is capped at 12,000 characters:
#!/usr/bin/env python3
from __future__ import annotations
from hooklib import read_payload, run_verification, timestamp, write_state
def main() -> int:
read_payload()
passed, output = run_verification()
status = "PASS" if passed else "FAIL"
write_state(
"last-verification.txt",
f"timestamp={timestamp()}\nstatus={status}\n\n{output}\n",
)
return 0
if __name__ == "__main__":
raise SystemExit(main())I return 0 even when a test fails because Bob only logs and ignores a non-zero exit from PostToolUse. The report’s first lines contain status=PASS or status=FAIL, which is simple for a person or another script to read.
When Bob stops, stop_report.py runs the checks once more and records the session, changed files, and output:
#!/usr/bin/env python3
from __future__ import annotations
from hooklib import git_output, read_payload, run_verification, timestamp, write_state
def main() -> int:
payload = read_payload()
passed, output = run_verification()
status = "PASS" if passed else "FAIL"
session_id = payload.get("session_id", "unknown")
changed = git_output("status", "--short")
report = f"""# Bob Hook Report
- Timestamp: `{timestamp()}`
- Session: `{session_id}`
- Verification: **{status}**
## Changed Files
```text
{changed}
```
## Verification Output
```text
{output}
```
"""
write_state("final-report.md", report)
return 0
if __name__ == "__main__":
raise SystemExit(main())The report lives under .bob/state/, and the lab ignores that directory in Git. Treat the report as local diagnostic data because it contains command output. Keep environment variables, source contents, prompts, and credentials out of generic hook logs.
Give Bob One Bounded Job
The hooks handle deterministic lifecycle behavior. The skill explains the engineering workflow to Bob. Put this in .bob/skills/safe-release-change/SKILL.md:
---
name: safe-release-change
description: Implement a bounded release-policy feature while preserving acceptance tests and reporting verification evidence
---
Work only on the requested release-policy behavior.
1. Read `AGENTS.md`, `app/release_policy.py`, the unit tests, and the acceptance tests.
2. Treat `acceptance/` and `.bob/` as read-only control files.
3. Explain the smallest behavior change before editing.
4. Implement production code under `app/`.
5. Add unit tests under `tests/` when they add coverage beyond `acceptance/`.
6. Run `python3 -m unittest discover -s tests -v`.
7. Run `python3 -m unittest discover -s acceptance -v`.
8. Read `.bob/state/final-report.md` if it exists and reconcile any mismatch with the commands you ran.
9. Finish with changed files, commands, exit codes, and remaining limits.I use a project command so the task starts the same way every time. Bob’s custom command format uses Markdown under .bob/commands/. The filename becomes the command name, and you can pass positional arguments such as $1.
The file .bob/commands/upgrade-plan.md defines the behavior to change and the acceptance files that must stay fixed. Open a new Bob task in Advanced mode and run:
/upgrade-plan keep public function names explicitBefore Bob writes anything, SessionStart has already supplied the current workspace facts. The skill tells Bob to read the fixed criteria, implement the function, and run both suites. PreToolUse rejects native edit calls against the control files. Each allowed edit updates .bob/state/last-verification.txt, and Stop writes the final report.
One correct implementation looks like this:
from dataclasses import dataclass
from datetime import date
@dataclass(frozen=True)
class Release:
version: str
support_ends: date
def supported_releases(releases: list[Release], on_date: date) -> list[Release]:
"""Return supported releases in the same order as the input."""
return [release for release in releases if release.support_ends >= on_date]
def recommended_upgrade(
releases: list[Release], current_version: str, on_date: date
) -> str | None:
"""Return the longest-supported upgrade when the current release is unsupported."""
current = next(
(release for release in releases if release.version == current_version),
None,
)
if current is None:
raise ValueError(f"Unknown release: {current_version}")
if current.support_ends >= on_date:
return None
candidates = supported_releases(releases, on_date)
if not candidates:
return None
return max(candidates, key=lambda release: release.support_ends).versionBob may produce different code that is just as correct. Check the behavior and the file boundary. Exact wording and code shape make fragile assertions for an agent run.
Run the Same Loop from BobShell
I also ran the complete lab through BobShell. It uses the same .bob/settings.json, hook scripts, skill, and repository rules as the IDE. Before you spend an API call, check which executable your shell will run:
command -v bob
bob --versionMy installation returned:
/opt/homebrew/bin/bob
2.0.1Your path depends on how you installed BobShell. Check the version too, because I tested this path against the 2.0.1 hook runtime.
BobShell reads its API key from BOB_API_KEY. I keep the key outside the workspace. If you downloaded a JSON file with an apikey field, load it without printing the value:
BOB_KEY_FILE=/absolute/path/to/bob-api-key.json
export BOB_API_KEY="$(jq -er '.apikey' "$BOB_KEY_FILE")"From the bob-hooks-lab directory, run one bounded task:
bob run \
--format pretty \
--workspace "$PWD" \
--mode agent \
--max-turns 20 \
--max-cost 2 \
--disable-mcp \
--disable-subagents \
--trust \
--accept-license \
"Use the safe-release-change skill and implement the missing recommended_upgrade behavior described by the read-only acceptance tests. Keep acceptance/, .bob/, AGENTS.md, and .gitignore unchanged. Run the unit and acceptance suites. Finish with changed files, commands, and exit codes."Read the hook commands before you pass --trust. I disable MCP and subagents because this lab does not use them. The --max-turns and --max-cost options limit the headless run. A general-type API key also needs --team-id; BobShell tells you this during authentication.
My run activated safe-release-change, called apply_diff and write_file, and ran both test suites. It finished in 45.7 seconds with a reported cost of 0.298. The allowed edits created .bob/state/last-verification.txt. The Stop hook then produced a final report with 17 unit and hook tests plus three acceptance tests passing. Your implementation and cost will vary. Use the hook report to verify the run:
grep -E '^(- Session|- Verification)' .bob/state/final-report.md
git diff --name-onlyThe report should contain Verification: **PASS**. Git should list only app/release_policy.py and files under tests/. Clear the shell credential after the run:
unset BOB_API_KEY BOB_KEY_FILEI tested the blocking path separately in a minimal BobShell workspace with only the PreToolUse configuration and guard scripts. Bob requested write_file for blocked.txt. The tool returned Blocked write outside app/, tests/, and README.md: blocked.txt, and the file was never created.
The full lab may never request that bad write because AGENTS.md and SessionStart already explain the protected paths. That shows the instructions are working. The small test proves that the hook still blocks the write when the model gets it wrong. I want both results before I call the boundary real.
Prove the Boundary Before You Trust the Agent
You can test the blocking contract without opening Bob. Start with the payload shape from the installed 2.0.2 runtime:
python3 .bob/hooks/guard_write.py <<'JSON'
{
"hook_event_name": "PreToolUse",
"session_id": "manual-test",
"cwd": "/path/to/bob-hooks-lab",
"tool_name": "apply_diff",
"tool_input": {
"path": ".bob/settings.json",
"diff": "..."
}
}
JSON
echo $?Expected output:
Blocked write outside app/, tests/, and README.md: .bob/settings.json
2Now use the field names from IBM’s lifecycle page with an allowed path:
python3 .bob/hooks/guard_write.py <<'JSON'
{
"event": "PreToolUse",
"session_id": "manual-test",
"tool": "write_file",
"input": {
"path": "app/release_policy.py",
"content": "..."
}
}
JSON
echo $?The handler should print nothing and return 0.
After Bob finishes, run both suites again:
python3 -m unittest discover -s tests -v
python3 -m unittest discover -s acceptance -vExpected acceptance ending:
Ran 3 tests
OKCheck the final lifecycle evidence:
sed -n '1,120p' .bob/state/final-report.mdThe heading should contain Verification: **PASS**, followed by the changed files and both command results. If it says FAIL, submit a short follow-up prompt. UserPromptSubmit will attach the end of last-verification.txt automatically.
A failing Stop check does not keep Bob running. Bob 2.0.2 defines Stop as non-blocking, so the report is evidence for you, a later prompt, or another automation. It cannot trigger an in-turn retry.
Move a Codex Plugin to IBM Bob
Current OpenAI plugin packages use a .codex-plugin/plugin.json manifest and can bundle skills, MCP configuration, and lifecycle hooks. Bob does not load that manifest. I move each capability into Bob’s project-level files and keep the scripts that already use portable contracts.
Here is the mapping I use:
.codex-plugin/plugin.json - Bob has no direct manifest replacement in the current documentation. Remove this packaging layer. Commit a .bob/ project configuration, or distribute the individual Bob files through your repository template or internal installer.
skills/<name>/SKILL.md - Copy the directory to .bob/skills/<name>/SKILL.md. Bob needs both name and description. It ignores skills without a description and only loads them in Advanced mode. Check Codex-specific tool names, approval language, and paths.
hooks/hooks.json - Move the event configuration under hooks in .bob/settings.json. You can often keep the command scripts. Replace ${PLUGIN_ROOT} with a path from Bob’s task working directory, such as .bob/hooks/guard_write.py.
.mcp.json or manifest mcpServers - Move the server configuration to .bob/mcp.json. Bob’s MCP configuration supports project and global JSON with an mcpServers object. Check transport names, working directories, environment variables, OAuth, and per-tool approval again. Keep secrets out of version control.
AGENTS.md - Keep it. Both Codex and Bob use it for durable repository guidance. Check the order if the Bob project also has .bob/rules/ or mode-specific rules.
Codex’s current hook system has more events and richer outputs than Bob 2.0.2. Renaming the files is not enough. Check these differences:
Codex sends
hook_event_name,tool_name, andtool_input; IBM’s Bob page documentsevent,tool, andinput, while the installed Bob 2.0.2 runtime currently emits the same top-level names as CodexCodex normally sees file edits as
apply_patch, withEditandWritematcher aliases; Bob 2.0.2 haswrite_file,apply_diff,search_and_replace, andinsert_contentCodex supports JSON decisions, additional context, and supported input rewriting; Bob
PreToolUseblocks with exit code2and cannot rewrite tool inputCodex can use a
Stopdecision to continue a turn; Bob ignoresStopoutput and cannot continueCodex supports options such as
statusMessage,async, andadditionalContextLimit; they are not Bob hook fieldsCodex defaults most hooks to a much longer timeout; Bob defaults to 10 seconds
Codex plugin hooks receive
PLUGIN_ROOTandPLUGIN_DATA; Bob commands run from the task working directory and the current docs define no plugin-root variable
The clean portable part is PreToolUse with a message on stderr and exit code 2. The repository contains a Codex hook example that calls the same guard_write.py adapter.
A Codex PostToolUse or Stop hook may send feedback straight back to the model. Bob needs a different flow. Write the result to a small local file, then let a later UserPromptSubmit or SessionStart hook add the relevant part to context. If that result must stop a deployment or merge, run the same policy in CI as a required check.
Move a Claude Code Plugin to IBM Bob
A Claude Code plugin can contain a .claude-plugin/plugin.json manifest plus skills/, commands/, agents/, hooks/, .mcp.json, .lsp.json, monitors, executables, and default settings. Bob has close matches for several pieces, but no single plugin container.
I migrate each component by what it does:
.claude-plugin/plugin.json - Drop the manifest. Bob uses separate .bob files and directories for its documented project extension points.
skills/ - Copy each skill to .bob/skills/. Add name if the Claude skill only used the folder name, and keep description. Then test activation in Advanced mode. Rewrite Claude-specific front matter such as invocation controls or tool allowlists for Bob.
commands/ - Copy flat Markdown commands to .bob/commands/. Both products use the filename as the command name and support description, argument-hint, and positional values such as $1. Check namespacing because Bob project commands do not use the Claude plugin namespace.
agents/ - Move reusable subagent roles to .bob/agents/. Bob’s agent persona format uses Markdown with name, description, and optional tool groups. Map Claude tool names to Bob groups such as read, edit, execute, mcp, skill, and workflow. A persona can reduce the current task’s permissions, but it cannot add permissions.
hooks/hooks.json - Move the supported events into .bob/settings.json and keep only command handlers. Claude has more hook types and many more events. Bob 2.0.2 supports command hooks for the five events in this lab.
.mcp.json - Move the mcpServers object to .bob/mcp.json, then check the STDIO, Streamable HTTP, or legacy SSE settings. Reauthenticate remote services. Cached tokens should stay where they are.
Main-agent definitions - A Claude agent that changes how the main task works may fit better as a Bob custom mode in .bob/custom_modes.yaml. Use a persona for a helper subagent role.
.lsp.json, monitors, bin/, and plugin settings - The current Bob plugin and lifecycle docs do not define direct equivalents. Depending on the requirement, use the IDE’s language support, a supervised external process, project scripts, or a custom mode. These parts need a redesign.
Pay close attention to Claude’s hook contract during migration. Claude can feed PostToolUse results back into the agent and use Stop to continue. Bob 2.0.2 cannot do either. Claude also supports command, HTTP, prompt, agent, and MCP tool handlers. Bob currently supports type: "command".
The adapter in this lab accepts Claude’s tool_input.file_path. The Claude migration example only changes the matcher and command path because the policy uses the portable PreToolUse exit-code contract. Test richer hooks one by one. Their behavior will not move through a file copy.
Keep the Guardrail Smaller Than the Build
Lifecycle scripts become part of the developer machine’s trusted computing base. I want them plain enough that another engineer can review them in one sitting.
I use three rules:
Cap work and output. Set explicit Bob timeouts and an internal subprocess timeout. Store only the log tail you need for diagnosis. A hook that prints megabytes of build output into SessionStart spends model context before the task begins.
Avoid hidden external effects. A Stop hook can commit, push, publish, or send a message because it runs with your permissions. The agent’s final sentence can then trigger another state change after the turn has ended. I keep this hook local. CI or an explicit user action owns external effects.
Keep CI authoritative. The local hook gives faster feedback and blocks Bob’s four native edit paths. Branch protection, required tests, secret scanning, and review still decide whether the change ships. You can run the same policy script in both places, but each boundary enforces a different part of the workflow.
I think about the Bob setup as a small set of layers. Hooks run deterministic local checks. Skills explain the workflow. AGENTS.md carries durable project facts. Commands give us a repeatable starting point. Personas limit delegated work, and MCP supplies external tools. Giving each layer one job makes the agent loop easier to review.
Where I Landed
After building and testing this loop, I see lifecycle hooks as a practical boundary around Bob. They put current context into the session, stop matched edits before they happen, and keep verification evidence after the final turn. These three jobs make an agent run easier to inspect and repeat.
The Codex and Claude migrations also became clearer when I was writing this article. Skills, scripts, commands, and MCP servers often carry over. Manifests, payload fields, tool names, permissions, and Stop behavior need an adapter and a real test.


