<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Quietai.dev</title>
    <description>The latest articles on DEV Community by Quietai.dev (@joosep_aru).</description>
    <link>https://dev.to/joosep_aru</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3945942%2Fdf70fff8-cdcf-4220-8396-d1bb0e4092f9.png</url>
      <title>DEV Community: Quietai.dev</title>
      <link>https://dev.to/joosep_aru</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/joosep_aru"/>
    <language>en</language>
    <item>
      <title>I built a Claude agent without a framework and something I didn't expect to find weird turned out to be weird</title>
      <dc:creator>Quietai.dev</dc:creator>
      <pubDate>Fri, 22 May 2026 11:10:31 +0000</pubDate>
      <link>https://dev.to/joosep_aru/i-built-a-claude-agent-without-a-framework-and-something-i-didnt-expect-to-find-weird-turned-out-103j</link>
      <guid>https://dev.to/joosep_aru/i-built-a-claude-agent-without-a-framework-and-something-i-didnt-expect-to-find-weird-turned-out-103j</guid>
      <description>&lt;p&gt;I built my first agent last night using the Anthropic Python SDK directly. No LangChain, no CrewAI, no orchestration framework. About 60 lines of code, one tool, one goal. The point was to see the mechanics without abstractions on top of them.&lt;br&gt;
Three things stood out. None of them were what I expected going in.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The conversation history array is the agent
There is no hidden state. The "memory" is a Python list you maintain yourself. Every iteration, you append the model's full response — including any tool_use blocks — and then append any tool results back into the list before the next API call.
pythonconversation_history = []
conversation_history.append({"role": "user", "content": user_input})&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;_while True:&lt;br&gt;
    response = client.messages.create(&lt;br&gt;
        model="claude-sonnet-4-6",&lt;br&gt;
        max_tokens=4096,&lt;br&gt;
        system=system_prompt,&lt;br&gt;
        tools=tools,&lt;br&gt;
        messages=conversation_history,&lt;br&gt;
    )&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;conversation_history.append({
    "role": "assistant",
    "content": response.content
})

if response.stop_reason == "tool_use":
    tool_results = []
    for block in response.content:
        if block.type == "tool_use":
            result = save_game_design(**block.input)
            tool_results.append({
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": result
            })

    conversation_history.append({
        "role": "user",
        "content": tool_results
    })
    continue

break_
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The non-obvious shape: tool results go back in as a user message, not assistant or system. The content is an array of tool_result blocks keyed by tool_use_id from the previous turn. Get this wrong and the model can't connect its own decision to the result of that decision.&lt;br&gt;
I expected this to feel like an abstraction. It doesn't. It feels like maintaining a transcript. Which is what it is.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;stop_reason == "tool_use" is the entire control flow
The loop terminates when the model's response has any stop_reason other than "tool_use" — usually "end_turn". That's it. There is no explicit "task complete" signal.
This is worth sitting with. The agent stops because the model decides not to call any more tools. Whether the task was actually completed correctly is something you have to verify outside the loop. The model's decision to stop is a judgment, not a status code.
In practice this means your system prompt is doing real work. Mine ended with:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Save the design when you think it's ready. Don't wait for permission.&lt;/p&gt;

&lt;p&gt;Without that last sentence, the model drafts, then asks "should I save this now?" — which kills the agentic behaviour. With it, the model just calls save_game_design() when it judges the work is done.&lt;br&gt;
The line between "obedient chatbot" and "agent that takes initiative" is one sentence in a system prompt. I had not expected the leverage to be that concentrated.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The tool's description field is documentation the model reads
&lt;em&gt;tools = [{
"name": "save_game_design",
"description": "Save the completed game design document to a file. "
               "Call this when you have a complete game design ready.",
"input_schema": {
    "type": "object",
    "properties": {
        "title": {"type": "string", "description": "The game title, used for the filename"},
        "content": {"type": "string", "description": "The full game design document in markdown format"}
    },
    "required": ["title", "content"]
}
}]&lt;/em&gt;
The description field is not for me. It is the only documentation the model has about when to call this tool. My first version had a vaguer description and the agent kept calling save mid-conversation, before the design was actually finished. Tightening it to "Call this when you have a complete game design ready" fixed the behaviour. Same for the per-parameter descriptions — they steer which arguments the model produces.
If your agent calls tools at the wrong times or with weird arguments, the fix is almost always in these strings.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three crashes worth saving you&lt;br&gt;
Outdated model string. claude-sonnet-4-20250514 is gone. Current at time of writing: claude-sonnet-4-6. The model itself will confidently give you the old string. Verify against current docs.&lt;br&gt;
Windows file encoding. If the model puts a Unicode character into a file write on Windows, you'll get UnicodeEncodeError: 'charmap' codec can't encode.... Fix:&lt;br&gt;
pythonopen(filename, 'w', encoding='utf-8')&lt;br&gt;
One parameter.&lt;br&gt;
API key in Git history. Commit .gitignore before .env. If your key ever touches a commit — even one you immediately reverted — rotate it on the Anthropic console. GitHub's secret scanner is good. It is not your security model.&lt;/p&gt;

&lt;p&gt;The actual thing I want to flag&lt;br&gt;
When I ran the working version, I gave the agent a vague prompt about an Estonian factory. It produced a complete game design document for a real 19th century textile mill I had not mentioned — three interlocking game loops, win and lose conditions pulled from historical events, scope estimate, art direction. Then it called save_game_design() on its own. I had been making coffee. When I came back the file was on my hard drive.&lt;br&gt;
What I want to flag is not that this happened. It is that I read what the agent had written and agreed with its judgment that the document was finished.&lt;br&gt;
That's a different sentence than "I was impressed." It is also a sentence I had not expected to be writing about a piece of Python I had just finished debugging.&lt;br&gt;
The frameworks (LangGraph, CrewAI) are elaborations of the loop above with conventions on top. Building the unframeworked version once is the fastest way to understand what those frameworks are actually doing.&lt;br&gt;
Full code and the design document the agent produced: github.com/quietaidev-collab/zero-to-agent&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
