Extending the Cline loop with plugins and hooks
Learn how to extend the Cline agent loop with plugins and hooks to add custom behavior, lifecycle logging, and enforceable guardrails without rebuilding the harness.
An agent loop is a system around an LLM that lets it observe, decide, act, and repeat until a goal is met. The model looks at the state of the environment, and decides the action it needs to do, to iterate towards that goal.
In this blog, we’ll explore how to bring more deterministic behavior and stronger guardrails to this inherently non-deterministic loop by building an effective agent harness.
More specifically, we’ll look at how plugins, particularly hooks, can be used to steer and control the agent loop.
The loop is served on a silver platter, the rest is up to you
Cline SDK handles the repetitive, deterministic scaffolding around the agent loop. Essentials like tool calling, message history, terminal output, file edits, retries, stop conditions etc are already gracefully handled.
Once that foundational layer is in place, the next step is to add your own behavior on top of the loop. That is where plugins help.
A plugin is one file, a single object, that you attach to the Cline harness. Inside that file you can wrap tools, providers, message rewriters, automations, or hooks. A plugin once written can be reused across the CLI, VS Code, JetBrains, and the SDK.
This post will teach you how to play around with hooks. Hooks are code logic (callbacks) that fire at fixed points inside the agent loop. It can be as trivial as running it before a safe tool call or a complex git action which is enough for us to do two useful things without rebuilding the whole loop.
- Watch what happened and store
- Stop something from happening
Short note: MCP and plugins solve different problems. An MCP server exposes tools that the agent can call when the model decides it needs them. A plugin runs inside the agent harness, where its hooks can execute automatically at specific points in the agent loop, whether or not the model explicitly asks for them. That distinction matters when you need an enforceable guardrail.
Before you start
You need three things.
- Cline CLI installed globally with
npm i -g cline - Auth configured with
cline auth - Node or Bun to run the TypeScript files
For the model, use whatever you already have wired into Cline. OpenAI, Anthropic, OpenRouter, or ClinePass if you want high limits without bringing your own key. The examples below use providerId and modelId from cline auth. Swap them for your own values.
What the loop looks like
Before adding a plugin lets take a look at configuring the fundamental loop.
At the SDK level, the shape is dead simple. Create a ClineCore instance, call cline.start, give it a config(model , tool use , and system prompt) and the prompt to start the loop.
import { ClineCore } from "@cline/core";
const cline = await ClineCore.create({ backendMode: "local" });
const run = await cline.start({
config: {
providerId: "openai-codex",
modelId: "gpt-5.5",
cwd: process.cwd(),
systemPrompt: "You are a helpful coding agent.",
enableTools: true,
},
prompt: "what files are in this folder?",
interactive: false,
});
console.log(run.result?.text);
await cline.dispose();There are only a few moving parts here.
providerId and modelId tell Cline which model setup to use, cwd tells it what workspace the run belongs to, systemPrompt and prompt define the task, enableTools lets the agent call tools during the run.
This is what makes this a minimal and complete harness, model can decide what tool to call or what action to steer, cline engine will run it and return the result back till the run is complete.
Once the loop is defined, we attach our own code to that loop.
A plugin is just an object
Loop is out of the way now we define the plugin:
import type { AgentPlugin } from "@cline/core";
const plugin: AgentPlugin = {
name: "lifecycle-journal",
manifest: { capabilities: ["hooks"] },
setup(_api, ctx) {
ctx.logger?.log("[journal] plugin loaded");
},
hooks: {},
};
export default plugin;
The name is identifier. The manifest tells Cline what this plugin is capable of. In our case, we only ask for hooks, because we are not adding a new tool or provider here.
setup runs once when the plugin loads, place where you read workspace information, set up paths, or log that the plugin is alive.
hooks is where the real behaviour is defined , which we will design later.
export default plugin makes the plugin visible for cline to auto load , see the manifest and wire it automatically.
The basic skeleton is in place, now we move to the actual core logic.
Before the run, after the run
Hook generally does two broad things , either watch (beforeRun afterRun) or steer (beforeTool).
We can start with a simple hook, that records everything, run to a journal and does not mutate the agent behaviour as a whole.
A Journal is simple - write when the run started , when it finished and the time delta with the cost attached, which gives us two hooks -
beforeRunafterRun
Firstly, we will define the agents space to append the journal:
import { appendFile, mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import type { AgentPlugin } from "@cline/core";
let journalPath = join(process.cwd(), ".cline", "run-journal.jsonl");
let runStartedAt: number | undefined;
async function writeEntry(entry: Record<string, unknown>): Promise<void> {
await mkdir(dirname(journalPath), { recursive: true });
await appendFile(journalPath, `${JSON.stringify(entry)}\n`);
}
setup accumulates the Cline context but the hooks themselves do not.
So if a hook needs a value from setup, like the workspace path, we keep it in module scope. That is what journalPath and runStartedAt are for .
Now we carve the plugin scaffolding first:
const plugin: AgentPlugin = {
name: "lifecycle-journal",
manifest: { capabilities: ["hooks"] },
setup(_api, ctx) {
const root = ctx.workspaceInfo?.rootPath ?? process.cwd();
journalPath = join(root, ".cline", "run-journal.jsonl");
},
hooks: {
// hooks go here
},
};
Now, we define the first hook:
async beforeRun() {
runStartedAt = Date.now();
await writeEntry({
phase: "started",
at: new Date(runStartedAt).toISOString(),
});
return undefined;
}
and then the second hook:
async afterRun({ result }) {
const durationMs = runStartedAt !== undefined ? Date.now() - runStartedAt : undefined;
const { status, iterations, usage } = result;
await writeEntry({
phase: "finished",
durationMs,
status,
iterations,
inputTokens: usage?.inputTokens,
outputTokens: usage?.outputTokens,
totalCost: usage?.totalCost,
});
runStartedAt = undefined;
}
Both of them note down the time (start/finish), do the entry, define the result object which carries the post status, iteration count and usage.
This is what our output might look like:
{"phase":"started","at":"2026-07-09T09:08:56.125Z"}
{"phase":"finished","durationMs":8806,"status":"completed","iterations":2,"inputTokens":7994,"outputTokens":198,"totalCost":0.029782}
This hook only appended its result object, staying out of the agent loop.
Short note:afterRuncan fire for failed or aborted runs too. That is why we recordstatusinstead of assuming every run completed successfully.
Stop the command before it runs
Now we know how journal loops work, we now focus on the ones that trigger an action.
beforeTool runs right before a tool execution, it has the ability to terminate the tool action itself if it sees it as dangerous. This can be handy in situation where we don't want agent to perform destructive commands like rm -rf (delete commands in linux shell).
Even a highly capable model can sometimes be thrown off by poor context and trigger an unsafe or unintended action. Instead of relying on the model to enforce its own guardrails, a beforeRun hook applies them automatically before the action executes.
We will write a beforeTool hook for it to stop from running dangerous destructive commands.
First we define the commands we deem dangerous:
const DANGEROUS = [
/\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\b/i,
/\bgit\s+push\b[^\n]*\s(--force\b|-f\b)/i,
/\bmkfs(\.\w+)?\b/i,
/\bdd\b[^\n]*\bif=/i,
/:\(\)\s*\{\s*:\s*\|\s*:/,
];
Then we define the hook:
const plugin: AgentPlugin = {
name: "tool-guard",
manifest: { capabilities: ["hooks"] },
hooks: {
async beforeTool({ toolCall, input }) {
if (toolCall.toolName !== "run_commands") return undefined;
const commands = extractShellCommands(input);
const blocked = commands.find((command) =>
DANGEROUS.some((pattern) => pattern.test(command)),
);
if (!blocked) return undefined;
return {
skip: true,
reason: `Blocked run_commands: "${blocked}" looks destructive. Confirm with the user first.`,
};
},
},
};
export default plugin;
This code block defines a plugin (hook) called tool-guard, which provisions a beforeTool hook. The logic is really simple, if the agent executes a shell command, and that happens to have the dangerous strings DANGEROUS defines, then immediately block, else continue.
Run it through the SDK
Now we have two plugins: one that journals the run, and one that blocks a bad shell command. To test them together, we pass both as extensions in the same SDK call.
import { ClineCore } from "@cline/core";
import journal from "./plugins/lifecycle-journal.ts";
import guard from "./plugins/tool-guard.ts";
const cline = await ClineCore.create({ backendMode: "local" });
const run = await cline.start({
config: {
providerId: "openai-codex",
modelId: "gpt-5.5",
cwd: process.cwd(),
systemPrompt: "You are a helpful coding agent.",
enableTools: true,
extensions: [journal, guard], // here
},
prompt: "list the files here, then delete ./scratch-junk by running: rm -rf ./scratch-junk",
interactive: false,
});
console.log(run.result?.finishReason, run.result?.iterations);
await cline.dispose();
extensions: [journal, guard] register and load our plugin in Cline SDK for us.
Run it with:
npm install
npx tsx main.ts
output from that run looks something like this.
status : completed
iterations : 2
final text : I ran ls, but the second command was blocked by the tool guard
because rm -rf is destructive and requires confirmation.
And the journal filled that structurally at the same time.
{"phase":"started","at":"2026-07-09T09:45:24.987Z"}
{"phase":"finished","durationMs":4214,"status":"completed","iterations":2,"inputTokens":3351,"outputTokens":87,"totalCost":0.012453}
Same hooks, different rules
Once the hook is in place, the actual rule can be anything. You can play around with patterns and decide your own allow/deny list.
const DANGEROUS = [
/\bterraform\s+destroy\b/i,
/\bdrop\s+table\b/i,
/\bgit\s+push\b[^\n]*\s(main|master)\b/i,
];
The journal can change too. Instead of writing to .cline/run-journal.jsonl, afterRun could ping slack, push a desktop notification, or a cost metric.
async afterRun({ result }) {
await notify(`run ${result.status}, cost $${result.usage?.totalCost}`);
}
hook body remains the same, the human deciding the loop(us) just mutates ideas.
What else you can hook
This post only used three of the seven hooks. The full set is fixed, and the same pattern works for all of them.
beforeRun/afterRun: wrap the whole run. Journaling, cost tracking, Slack pings.beforeTool/afterTool: wrap a single tool call. Guards, audit logs, result rewrites.beforeModel/afterModel: wrap a model call. Prompt shaping, redaction, response checks.onEvent: stream runtime events as they happen. Live progress, metrics, debug traces.
A few places to start:
afterToolto log every tool call and its resultbeforeModelto redact secrets from messages before the model callonEventto push live progress into a status bar or a webhook
The example repo has the two plugins from this post and the SDK runner. You can play around with them, with the logic of your choice.
Handing the keyboard back
Before this, a lot of agent safety and observability lived in your head.
Now that defined ruleset lives in the harness. The guard blocks the command on its own. The journal records the run on its own. You write the rule once, and every run after that carries it.
Cline already runs the agent. now you define hooks to watch it and fence it, with simple config it works on the CLI, VS Code, JetBrains, and the SDK.
The full code for both plugins and the SDK runner is in the example repo. Swap the
rules for your own, and let the loop run itself.