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 + Harness and what the word Harness really means.

  • We’ll explore what context is and how to manage it.

  • 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.

when the context has grown too large
when the context has grown too large

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

No difference
No difference

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            |
Or like this
Or like this

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.

When you've spawned 100 subagents
When you've spawned 100 subagents

There are two patterns for handling results:

  1. The subagent delivers the result itself when it’s ready.

  2. 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 log or cargo 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 in SKILL.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:


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

Context

An array of messages that you carry between requests

Tools

Functions with a JSON schema that the model calls on its own

Memory

Regular tools for storing and retrieving long-term information

Subagents

The same as tools, but they call another agent

Skills

SKILL.md + a couple of scripts, injected into the context on demand via a tool

Integrations

Ready-made tools / MCPs for hundreds of services

Messenger

A regular Telegram bot, you know how to do this

Background tasks

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