Hello, Habr! My name is Nikita Pastukhov—author of FastStream, Principal Engineer, and maintainer of AG2 (a framework for agent development). I’ve been in development for 8 years, and for the last year, I’ve been up to my ears in agents.
And I want to prove to you that writing your own agent is no more difficult than writing a CRUD application.
Why does this even need to be proven? Because there’s a noticeable gap between what’s happening with AI in the world and what’s happening in the average Russian company:
The World | Russia |
|---|---|
Every company has a subscription to OpenAI / Claude / Copilot | DANGEROUS, we host our own models |
A billion startups making AI products | Unclear |
AI is deeply integrated into the back office—meetings, documents, SRE | Support chatbots |
A2A, UCP, internet of agents | Adopting MCP |
Engineers know how to develop agents | What is that anyway? |
So let’s break down how agents work using OpenClaw as an example—the most hyped “personal AI agent” right now. It lives in your messenger, sorts your email, manages your social media, writes code, and deploys services. Its popularity is a testament to how little people are currently using agents in their daily lives. For those in the know, OpenClaw brought nothing new to the table.
TL;DR
This article turned out to be quite long, but don’t be intimidated—I’ve tried to make it as clear and accessible as possible. Here’s a TL;DR to make diving in less scary.
What an agent is - we’ll break down the basic formula
Agent = LLM + Harnessand what the word Harness really means.We’ll go over key mechanisms: tools, MCP, memory, subagents, and Skills.
We’ll separately look at practical use cases: integration with a messenger, background cron jobs, external integrations, and dynamic skill loading.
We’ll touch upon security.
And, of course, we’ll draw some conclusions.
Why write your own agent
Before we dissect OpenClaw—why should you write your own agent instead of using a pre-built one?
Specialized is better than general-purpose. OpenClaw does everything: email, social media, code, deployment. And it does it all poorly. An agent tailored for a single task performs it incomparably better.
Less functionality means a smaller attack surface. An agent with access to email, messengers, a codebase, and deployment is a very interesting target. Why give it permissions for everything if you only need it to do one thing? Besides, knowing your own code means you know how to secure it.
You can address your specific needs. No universal agent knows your workflow, your tools, or your habits. Your own agent will.
It’s just fun.
So, let’s take OpenClaw apart piece by piece—and build our own.
What an agent is
Fun fact: in August 2025, I started working with AG2—a company that deals with agents and builds a framework for their development. The first question I asked my colleagues was, “Guys, you’re making agents here—can anyone explain what that is?”—and I was met with silence.
There’s no formal definition. Or maybe I was asking the wrong people and reading the wrong articles. But it’s not really necessary. Everyone involved in agent development has an intuitive understanding: it’s an LLM + 10,000 hoops to jump through for managing context, memory, security, and tools.
Nowadays, it’s commonly formulated like this:
Agent = LLM + Harness
The word Harness itself doesn’t mean much—it’s a catch-all term for all the work you need to do around an LLM to make it solve real-world problems:
context management
tools
memory
skills
multi-agent logic
integrations with external systems
In short—everything we “harness” an LLM with to make it do what we need.
And what is an LLM in this paradigm? Very simple: an LLM is the agent’s brain an HTTP endpoint. It does exactly one thing: it accepts a JSON and returns a JSON.
User -- {"role": "user", "content": "Hello!"} --> LLM User <-- {"role": "assistant", "content": "Oh god, what is it now!?"} -- LLM
Everything else is the Harness. Let’s break it down piece by piece.

Context and its management
Context is the complete history of your interaction with the model. The LLM API is a stateless service, so you need to pass the entire context with every request.
Something like this:
// first request User -- [{"role": "user", "content": "Hello!"}] --> LLM User <-- {"role": "assistant", "content": "Oh god, what is it now!?"} -- LLM // second request User -- [ // history {"role": "user", "content": "Hello!"}, {"role": "assistant", "content": "Oh god, what is it now!?"}, // new message {"role": "user", "content": "Nothing much, just bored"} ] --> LLM
By the way, the system prompt is just the first message in the context, formatted like this:
{"role": "system", "content": "..."} // So the first request actually looks like this User -- [ {"role": "system", "content": "..."}, {"role": "user", "content": "Hello!"} ] --> LLM
And everything you’ve stuffed into the request is the agent’s context. The context window is how large of a JSON the model can actually process.
In code, it looks something like this:
from autogen.beta import Agent, config agent = Agent("agent", config=config.OpenAIConfig("gpt-4")) # Make the first request turn = await agent.ask("Hi!") print(await turn.content()) # "Hi, how can I help you?" while True: # Make the next request based on the previous one turn = await turn.ask("Continue") print(await turn.content()) # "What should I continue?"
And this is where we encounter the main problem with context—it grows.
500t | user | <- new request includes the entire history 300t | agent | | user | user | 100t | agent | agent | | user | user | user | | system | system | system |
And it grows non-linearly. That is, for each subsequent request, you send an increasingly larger JSON to the model. And that means tokens and money.

The good news is that the context is cached. So, you’re actually sending something like this:
50t | user | <- you mainly pay for new tokens 50t | | | user | | 100t | | | | user | 250ct | 450ct | <- ct = cached tokens | system | cached | cached | <- the old part of the context is cached
Cached tokens might either just be cheaper or even free (for example, with a Claude Max subscription). But even so, they consume limits, so the general rule is—if you’re done with the current task, start a new one in a different chat. The model will respond more accurately, and you’ll save tokens.
Context Compaction
This is the most basic functionality for any agent. If the context grows too large, it needs to be compressed. And since it’s just a JSON, you can compress it in any way you like:
discarding old messages
discarding only certain types of messages
compressing the entire dialogue into a single
<summary>using the same LLM
The last option is the simplest and most popular. In code, it looks something like this:
from autogen.beta import Agent, config compaction_agent = Agent("compacter", config=config.OpenAIConfig("gpt-5")) agent = Agent("my-lovely-agent", config=config.OpenAIConfig("gpt-5")) turn = await agent.ask("Hi!") while True: history = turn.stream.history messages = await history.get_messages() if len(messages) > 10: summary = await compaction_agent.ask( "Summarize chat history into a single message", f"History: {messages}" ) # overwrite the entire history with a single message await history.set([summary.body]) turn = await turn.ask("Continue") print(turn.body)
This is a simplified example to understand the mechanics. Frameworks usually have built-in components for this: middleware, context management policies, etc.
By the way, congratulations. You’re now capable of writing ChatGPT (not the model, but the web chat).
Tools
Tools are a very important thing that has allowed agents to interact with the outside world. Now they are not limited to chat; they can act.
From the LLM’s perspective, a tool is just another JSON in the context:
{ "name": "get_weekday", "description": "Call this tool each time you want to know the current weekday", "arguments": { "type": "object", "properties": {} } }
name—a unique identifier by which the model will call the tool
description—our attempt to explain to the model what it’s for and when to use it
arguments—a JSONSchema describing the arguments; we just hope the model returns the correct JSON
How a call happens
The model sees the signature, and during the dialogue, it decides to use the tool and returns a command to execute it:
User -- [{"role": "user", "content": "What should we do today?"}] --> LLM LLM -- { "role": "assistant", "tool_calls": [{ "call_id": "...", "name": "get_weekday", "arguments": "{}" }] } --> Agentic Framework LLM <-- { "role": "tool", "content": "Friday" } -- Agentic Framework User <-- { "role": "assistant", "content": "Friday! It's time to drink beer!" } -- LLM
The context at this moment:
| assistant | | tool result | <- tool result | tool call | <- command to call the tool | user | | tools definitions | <- descriptions of available tools | system |
From a code perspective, a tool is just a function:
from autogen.beta import Agent, config agent = Agent("my-lovely-agent", config=config.OpenAIConfig("gpt-5")) @agent.tool def get_weekday() -> str: return "Friday" # always Friday, always drinking beer
The framework itself parses the name, description, and arguments, puts them into the context, calls the function, and returns the result to the model.
Capabilities of tools
A tool is literally any code you can attach to a model. Tools are used to implement:
Memory
Subagents
Agent access to the internet
Integrations with external systems (Google Docs, Notion, Maps, etc.)
Interaction with the operating system
AI-IDE (reading, editing files, running commands)
The problem of overtooling

The more tools, the better the agent? No.
If 95% of the context consists of tool descriptions, and the user’s query gets lost in the noise—don’t be surprised if the model starts doing crazy things.
I saw a funny example: a model was given 120 tools, and no matter what you asked it to do, it would just call random tools in a random order.
A shout-out to those who love enabling 100,500 MCPs and SKILLS in their IDE.
The general rule: don’t enable tools that aren’t needed in the current context. This is one of the problems that subagents and skills help solve—we’ll talk about them later.
What does MCP have to do with this?
MCPs are tools that are accessible from another process via HTTP or a socket. At the beginning of a dialogue, the agent requests a description of methods from the MCP server, and when calling one, it sends a command and receives the result. From the model’s and context’s perspective—there’s no difference. But a single MCP server for database operations can serve hundreds of agents in parallel without pulling the code into every codebase. And it can be updated independently of the agents themselves.
Memory

Context is the history of the current conversation. But we’d like the agent to accumulate knowledge about the world and our interactions with it:
information about the user
its own personality (communication style)
general rules from experience
dialogue history
As you’ve probably guessed by now: memory is also implemented with tools. A set of functions for reading, writing, and searching through memories:
class Memory: def write_conversation_memory(name: str, summary: str) -> None: ... def list_conversations() -> list[tuple[UUID, str, datetime]]: ... def read_conversation(conversation_id: UUID) -> str: ...
In its simplest form, memory is a directory on the file system. Here’s how it’s structured in OpenClaw:
memory/ ├── PERSONALITY.md # agent's personality ├── USER.md # user profile ├── 04_16_2026/ # dialogue history │ ├── Write_Blogpost.md │ └── Make_Presentation.md └── 04_17_2026/ └── Find_NN_Restaurants.md
With such a directory and a couple of tools to work with it, the agent can:
self-modify its system prompt (via
PERSONALITY.md)update information about the user (
USER.md)write dialogue history, search through it, and retrieve facts
The mechanics are simple: PERSONALITY.md and USER.md are read by a tool and inserted into the system prompt every time a new chat starts—either the agent calls read_personality() itself at the beginning of a session, or you do it forcibly. The dialogue history, on the other hand, is loaded only on demand when something needs to be recalled. This way, the context doesn’t constantly bloat, but facts about the user are always at hand.
By the way, RAG is the exact same set of tools. The only difference is the implementation: instead of a file system, there’s a vector database inside.
And that’s all the “magic” behind agents that self-modify their prompts and remember everything.
Subagents
Imagine you ask an agent, “we were choosing a restaurant last week, remind me what we decided.” The agent can only look at history by day—and starts iterating:
User -- "We were choosing where to go last week. What did we decide?" --> LLM LLM -- list_memories(date="04_15_2026") --> Framework LLM <-- [] -- Framework LLM -- list_memories(date="04_16_2026") --> Framework LLM <-- ["Write_Blogpost", "Make_Presentation"] -- Framework LLM -- list_memories(date="04_17_2026") --> Framework LLM <-- ["Find_Restaurants"] -- Framework LLM -- read_file(path="04_17_2026/Find_Restaurants.md") --> Framework User <-- "It was on the 17th! You decided to go to Yel, table for 9:00 PM" -- LLM
The final context looks like this:
| assistant | | tool result | <- intermediate result #3 | tool call | <- call #3 | tool result | <- intermediate result #2 | tool call | <- call #2 | tool result | <- intermediate result #1 | tool call | <- call #1 | user | | tools definitions | | system |

There’s a lot of intermediate noise in the context. All of it was needed to answer one question—but it’s useless for the rest of the dialogue.
Solution: let a subagent handle the subtask. We wrap the call to another agent in a tool—it has an isolated context, and only the final result makes it into the main context:
from autogen.beta import Agent, config, tools memory_agent = Agent( "memory-agent", config=config.OpenAIConfig("gpt-5"), tools=[tools.FilesystemToolkit("./memory")] ) agent = Agent( "ag-claw", config=config.OpenAIConfig("gpt-5"), tools=[ memory_agent.as_tool(description="Find information in memories") ] )
Instead of one noisy context, we have two small, isolated ones:
| assistant | | | subagent result | assistant | <- only the final result enters the main context | | tool result | | | tool call | | | tool result | | | tool call | | | tool result | | | tool call | <- all the noise stays inside the subagent | subagent call | user | | user | | | subagent tools | memory tools | | claw prompt | subagent prompt | <- two isolated contexts
It’s important to understand here:
A subagent is not a service or a module. It’s just a subcontext. It has its own system prompt and its own history. But the model is often the same.
Subagents are the most common pattern for multi-agent interaction right now because they are the simplest yet quite effective. They also provide a clean solution to the overtooling problem: instead of one agent with 50 tools, you have several agents with 5–10 tools each.
Background and parallel subagents
A subagent doesn’t have to block the main dialogue. The main agent assigns a task, releases control, and the dialogue continues—when the subagent finishes, the result is added to the context:
| assistant | | | user | | | subagent result | assistant | <- the result arrives asynchronously | | tool result | | assistant | tool call | | user | tool result | | | tool call | | subagent called | tool call | <- the subagent runs in the background | subagent call | user | | user | |
And since an LLM can call multiple tools simultaneously, a single request can start several parallel subtasks. Powerful, but use with caution: tokens will burn quickly.

There are two patterns for handling results:
The subagent delivers the result itself when it’s ready.
The subagent returns a TaskId, and the main agent polls for readiness using this ID.
Which one suits you depends on the task. Like everything else in this world.
Dynamic subagents
There’s also an option where an agent generates subagents on the fly.
Again, no magic here—we just have a tool that takes as input:
a system prompt for the dynamic agent
a set of tools for it
which model to use
This tool generates an agent, and then we immediately set it on the required subtask.
Skills
Skills are a way to teach an agent to perform highly specialized tasks without constantly bloating the context.
If you actively use coding agents (Claude Code, Cursor, Codex), you’ve already encountered them. Here are a few real-world examples:
rtk—teaches an agent to use rtk as a proxy for shell commands: instead of the raw output of
git logorcargo build, the agent gets a filtered result and uses far fewer tokens.caveman—teaches an agent to write primitive but predictable code without over-engineering.
React best practices—guidelines for React from Vercel that the agent loads before working with the frontend.
The formula is simple:
Skill = Context + Scripts
The structure on the file system:
.agents/skills/ └── Pytest_Skill/ ├── SKILL.md └── scripts/ ├── run_pytest.sh └── list_tests.py
SKILL.md—a text instruction that we’ll load into the context when the agent wants to work with pytest.scripts/—executable scripts whose usage rules are described inSKILL.md.
To work with skills, an agent needs a couple of tools:
class SkillsToolkit: def list_skills() -> list[SkillMetadata]: ... def load_skill(skill_id: str) -> str: ... def run_skill_script(skill_id: str, script: str) -> str: ...
For the model to know what skills it has, you need to provide it with information about them in the context (similar to tools):
[{ "name": "Pytest_Skill", "description": "Use this skill to test your python code", ... // various useless fields }]
The context when working with a skill:
| assistant | | script result | | run script | <- executing a script from the skill | skill content | | load skill | <- loading the skill into the context | user | | tools definitions | | skills metadata | <- list of available skills | system |
In summary, skills are:
metadata in the context
a couple of tools
a directory on the file system
But this allows you to load huge instructions for specific tasks on demand, rather than keeping them in the context at all times. You shouldn’t register too many skills either—their metadata also takes up space. A good solution: delegate skill management to a separate subagent.
Dynamic skills
The final feature—loading skills from the internet directly during a dialogue:
class SkillSearchToolkit: async def search_skills(query: str, limit: int = 10) -> str: ... async def install_skill(skill_id: str) -> str: ... def remove_skill(name: str) -> None: ...
We search for skills on skills.sh via an API, download them from GitHub, and install them in a local folder. AG2 has a ready-made autogen.beta.tools.SkillSearchToolkit for this.
External Integrations
This is simple: integrations are just tools (or MCPs) aimed at external systems. And there’s no need to reinvent the wheel—there are plenty of catalogs with ready-made solutions:
aci.dev/tools—600+ ready-to-use tools, open source
composio.dev/toolkits—1000+ toolkits with OAuth out of the box
arcade.dev—MCP runtime, focused on secure agent authorization
mcp.so—a catalog of MCP servers (community)
Messenger Integrations

The main problem when integrating an agent with any UI is context management. This can be handled through different chats or explicit commands indicating that the current dialogue is over and a new one should begin. And if your agent is designed for multiple users, you also need to separate their contexts and not forget about security.
But these are not tasks unique to agent development. Any web developer has done something similar, and I’m sure you can handle it too.
To help, I can offer this piece of code:
from autogen.beta import Agent, config, MemoryStream agent = Agent("tg-agent", config=config.OpenAIConfig("gpt-5")) dp = Dispatcher() chat_state: dict[int, MemoryStream] = {} @dp.message(F.text) async def on_text(message: Message) -> None: # get the old context or create a new one if not (stream := chat_state.get(message.chat.id)): stream = chat_state[message.chat.id] = MemoryStream() # call the agent with this context reply = await agent.ask( message.text, stream=stream, variables={"user_id": message.chat.id}, ) # reply in the TG chat await message.answer(reply.content) asyncio.run(dp.start_polling(bot))
I’ve described a more detailed example in my blog—it shows dialogue history and switching between chats.
But I’m confident you can handle such an integration without any trouble. If you want to write a web application, I recommend looking at the features of the AG-UI protocol—it already has ready-made frameworks for the frontend.
Background tasks
One of OpenClaw’s praised features is “tell the agent to monitor a flight ticket website every hour and buy them as soon as they appear.” These are cron jobs that the agent can register itself.
Of course, you need tools for this:
class SchedulerToolkit: def schedule_task(task_prompt: str, cron: str) -> UUID: ... def remove_task(task_id: UUID) -> None: ... def list_tasks() -> list[UUID]: ...
And a scheduler that runs alongside the agent and calls it at the appointed time:
import asyncio from autogen.beta import Agent, config cron = Scheduler() agent = Agent( "tg-agent", config=config.OpenAIConfig("gpt-5"), tools=[SchedulerToolkit(cron)] ) async def main(): asyncio.create_task(cron.run()) await agent.ask("Monitor tickets every hour")
The agent creates tasks to call itself at a specific time with a given prompt. That’s all the magic there is to it.
Briefly about security
An agent with access to the file system, a messenger, and external services is an interesting target. Two things to think about from the very beginning:
Prompt injection. Malicious text from an external source (an email, a web page, a document) can get into the context and override the agent’s behavior. Validate what you put into the context from external systems (both tool results and user input). If the agent reads emails, don’t let it automatically execute instructions from them.
Principle of least privilege. Don’t give the agent tools it doesn’t need. An agent for working with email shouldn’t be able to deploy services. Fewer tools mean a smaller attack surface and less chance that the model will call something it shouldn’t.
For reliability, it’s best to run the agent in a sandbox, for example, in a container.
Summary
We’ve gone through all the components of OpenClaw—and none of them turned out to be rocket science:
Component | What it really is |
|---|---|
An array of messages that you carry between requests | |
Functions with a JSON schema that the model calls on its own | |
Regular tools for storing and retrieving long-term information | |
The same as tools, but they call another agent | |
| |
Ready-made tools / MCPs for hundreds of services | |
A regular Telegram bot, you know how to do this | |
Cron that triggers the agent on a schedule |
All of this follows a single pattern. You send a JSON to an LLM, get a JSON back, execute a command, and put the result into the context. Repeat. That’s the entire Harness.
Developing an agent is no more difficult than writing a CRUD application. The only difference: instead of a database, you have an LLM, and instead of REST endpoints, you have tools.
Remember the table at the beginning? I hope agents no longer seem like a black box to you. They stop being magic the moment you look at them from the inside.
So you don’t need OpenClaw. Now you can write an agent for your own tasks.
All code examples in this article are written using AG2 Beta—a completely new version of the framework I’m currently developing. We plan to use it as the primary version for the transition to 1.0. If you want to participate in OpenSource development—we are looking for users, contributors, and feedback—please join us.
And in my Telegram channel, I write about agents, OpenSource, development, and other things that interest me.
Links
My Telegram channel—where I talk about agents, OpenSource, development, and productivity
TG chat about AI—where we discuss skills, development practices, and agent usage
github.com/ag2ai/ag2—the framework on which all the examples above are written
aci.dev/tools | composio.dev/toolkits | arcade.dev—catalogs of ready-made integrations
mcp.so—a catalog of MCP servers
github.com/openclaw/openclaw—if you still want to check out the original