<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>AI &#8211; Other Things</title>
	<atom:link href="https://blog.adamzolo.com/category/ai/feed/" rel="self" type="application/rss+xml" />
	<link>https://blog.adamzolo.com</link>
	<description>Blog about Things by Adam Zolotarev</description>
	<lastBuildDate>Fri, 03 Jul 2026 15:44:41 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0</generator>
	<item>
		<title>Self-Hosted AI Agent Setup</title>
		<link>https://blog.adamzolo.com/self-hosted-ai-agent-setup/</link>
					<comments>https://blog.adamzolo.com/self-hosted-ai-agent-setup/#respond</comments>
		
		<dc:creator><![CDATA[Adam Zolo]]></dc:creator>
		<pubDate>Fri, 03 Jul 2026 15:44:41 +0000</pubDate>
				<category><![CDATA[AI]]></category>
		<guid isPermaLink="false">https://blog.adamzolo.com/?p=1117</guid>

					<description><![CDATA[I landed on OpenClaw, a self-hosted AI gateway. You install it on a server, connect it to a model provider, and it gives you agents with sessions, skills, Telegram integration, and cron jobs. Here&#8217;s how mine is put together. The stack The server The base install is a curl-to-bash script plus an auth token. For&#8230;<p><a class="more-link" href="https://blog.adamzolo.com/self-hosted-ai-agent-setup/" title="Continue reading &#8216;Self-Hosted AI Agent Setup&#8217;">Continue reading <span class="meta-nav">&#8594;</span></a></p>]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">I landed on OpenClaw, a self-hosted AI gateway. You install it on a server, connect it to a model provider, and it gives you agents with sessions, skills, Telegram integration, and cron jobs. Here&#8217;s how mine is put together.</p>



<h2 class="wp-block-heading">The stack</h2>



<ul class="wp-block-list">
<li><strong>OpenClaw</strong>: agent runtime</li>



<li><strong>Hetzner VPS</strong>: €4/month, Ubuntu</li>



<li><strong>Tailscale</strong>: remote access without opening ports</li>



<li><strong>OpenRouter</strong>: model provider</li>



<li><strong>SearXNG</strong>: self-hosted search so agents can look things up</li>



<li><strong>Telegram</strong>: main interface on mobile</li>
</ul>



<h2 class="wp-block-heading">The server</h2>



<p class="wp-block-paragraph">The base install is a curl-to-bash script plus an auth token. For access, I point Tailscale Serve at the local port, which gives me the Control UI over HTTPS from any device on my tailnet.</p>



<h2 class="wp-block-heading">The agents</h2>



<p class="wp-block-paragraph">Four agents run right now, each with its own workspace, system prompt, and model:</p>



<ul class="wp-block-list">
<li><strong>Jake</strong>: general assistant, lives in Telegram DM</li>



<li><strong>Scheduler</strong>: calendar and travel, bound to a Telegram group, sends a daily briefing at 7pm</li>



<li><strong>Sage</strong>: retirement and market advisor, posts a weekly summary on Mondays</li>



<li><strong>Doc</strong>: health coach in a dedicated group where I log meals, workouts, and sleep</li>
</ul>



<p class="wp-block-paragraph">OpenRouter makes the per-agent model choice easy: the daily briefing runs on something cheap and fast (mimo-v2.5-flash), while the advisor gets a stronger model. Each agent&#8217;s personality and scope comes from a <code>SYSTEM.md</code> in its workspace. Doc&#8217;s, for example, sets the ground rules &#8211; evidence-based, concrete numbers, no diagnosing, and never start a message with &#8220;Great question!&#8221;</p>



<p class="wp-block-paragraph">Adding an agent is a short routine:</p>



<pre class="wp-block-code"><code>openclaw agents add health --non-interactive \
  --workspace /root/.openclaw/agents/health/workspace --json
openclaw agents set-identity --agent health --name "Doc" --emoji "&#x1fa7a;"</code></pre>



<p class="wp-block-paragraph">Then I copy the auth profiles over from the main agent, double-check the OpenRouter base URL in <code>models.json</code> (it must be <code>https://openrouter.ai/api/v1</code>, with the <code>/api</code>), delete the generated <code>BOOTSTRAP.md</code> from the workspace, and write the system prompt. The base URL and <code>BOOTSTRAP.md</code> steps matter: getting either wrong produces an agent that silently returns empty responses.</p>



<h2 class="wp-block-heading">Routing Telegram groups to agents</h2>



<p class="wp-block-paragraph">Each agent that lives in a group gets a route binding in <code>openclaw.json</code>, matched on the group&#8217;s chat ID:</p>



<pre class="wp-block-code"><code>{
  "type": "route",
  "agentId": "health",
  "match": {
    "channel": "telegram",
    "peer": { "kind": "group", "id": "-12345" }
  }
}</code></pre>



<p class="wp-block-paragraph">I write these by hand rather than using <code>openclaw agents bind</code>, because the generated bindings match on the provider&#8217;s <code>accountId</code>, which is the same for every Telegram group. Fine with one agent, wrong with several. Matching on <code>peer.id</code> sends each group to its own agent.</p>



<p class="wp-block-paragraph">To find a group&#8217;s chat ID, I create the group, add the bot, send it a message, and grep the gateway logs for <code>chatId</code>. The negative number is the ID.</p>



<h2 class="wp-block-heading">Web search</h2>



<p class="wp-block-paragraph">Agents get web search through a local SearXNG instance. Two setup details worth knowing: SearXNG only reads its config from <code>/etc/searxng/</code> (or a path in <code>SEARXNG_SETTINGS_PATH</code>), and OpenClaw&#8217;s <code>web_fetch</code> tool blocks private IPs, including <code>127.0.0.1</code> and the Tailscale range. So instead of pointing agents at localhost, I run a small socat relay from <code>0.0.0.0:8889</code> to <code>127.0.0.1:8888</code> and give the agents the VPS&#8217;s public IP. Not elegant, but it&#8217;s been solid.</p>



<h2 class="wp-block-heading">What it costs</h2>



<p class="wp-block-paragraph">The VPS is €4/month, and model usage through OpenRouter comes out to fractions of a cent per conversation. Even with four agents and daily scheduled jobs, the model bill rounds to pocket change. Sessions are stored as plain JSONL files, which I&#8217;ve come to appreciate: they&#8217;re trivial to back up, and when I&#8217;m curious what an agent actually sent to the model, I can just read the file.</p>



<p class="wp-block-paragraph">The result is a set of assistants that are always on, reachable from my phone, and entirely on hardware and accounts I control. The morning briefing shows up whether or not I&#8217;ve opened a laptop, and adding a new agent for a new corner of life is a fifteen-minute job.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.adamzolo.com/self-hosted-ai-agent-setup/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Using tools for LLM&#8217;s instead of asking</title>
		<link>https://blog.adamzolo.com/claude-api-tool-use-vs-prompting/</link>
					<comments>https://blog.adamzolo.com/claude-api-tool-use-vs-prompting/#respond</comments>
		
		<dc:creator><![CDATA[Adam Zolo]]></dc:creator>
		<pubDate>Tue, 17 Feb 2026 03:26:32 +0000</pubDate>
				<category><![CDATA[AI]]></category>
		<category><![CDATA[Rails]]></category>
		<category><![CDATA[Ruby]]></category>
		<guid isPermaLink="false">https://blog.adamzolo.com/?p=1106</guid>

					<description><![CDATA[I have a Rails app that sends user-provided text to Claude for analysis and displays structured results in the UI. The response needs to be JSON so I can render it. However longer inputs sometimes would generate errors. Longer inputs meant longer system prompts and longer responses. The logs showed: Analysis failed: expected ',' or&#8230;<p><a class="more-link" href="https://blog.adamzolo.com/claude-api-tool-use-vs-prompting/" title="Continue reading &#8216;Using tools for LLM&#8217;s instead of asking&#8217;">Continue reading <span class="meta-nav">&#8594;</span></a></p>]]></description>
										<content:encoded><![CDATA[<p>I have a Rails app that sends user-provided text to Claude for analysis and displays structured results in the UI. The response needs to be JSON so I can render it.</p>
<p>However longer inputs sometimes would generate errors. Longer inputs meant longer system prompts and longer responses.</p>
<p>The logs showed:</p>
<pre><code>Analysis failed: expected ',' or '}' after object value</code></pre>
<p>Claude was generating valid-looking JSON that wasn&#8217;t actually valid. A dropped comma deep in a large response object. The longer the response, the more likely this happened.</p>
<h2>The Old Approach: Prompt Engineering + Defensive Parsing</h2>
<p>My system prompt included a 28-line block demanding JSON output:</p>
<pre><code class="language-ruby">SYSTEM_PROMPT = &lt;&lt;~PROMPT
  ...
  CRITICAL INSTRUCTIONS:
  - You MUST ALWAYS respond with valid JSON. No exceptions. No explanations outside JSON.
  - NEVER respond with plain text - always use the JSON format.

  You MUST ALWAYS respond in JSON format with the following structure (no exceptions):
  {
    "score": 0-100,
    "level": "low|medium|high",
    "summary": "Brief overall assessment",
    "items": [
      {
        "title": "Issue name",
        "description": "What's wrong",
        "severity": "low|medium|high"
      }
    ],
    "recommendations": [
      "Specific actionable suggestion 1",
      "Specific actionable suggestion 2"
    ]
  }
PROMPT</code></pre>
<p>Despite all the shouting in the prompt, Claude would sometimes:</p>
<ul>
<li>Wrap the JSON in markdown code fences (<code>```json ... ```</code>)</li>
<li>Add explanatory text after the closing brace</li>
<li>Drop commas in deeply nested objects on long responses</li>
<li>Return plain text when it decided the input wasn&#8217;t suitable for analysis</li>
</ul>
<p>So I built a pipeline of defensive code to handle all of this.</p>
<p><strong>Step 1: Extract JSON from whatever Claude returned.</strong> A brace-matching parser that stripped markdown fences, found the first <code>{</code>, tracked nesting depth while respecting string escaping, and separated trailing notes:</p>
<pre><code class="language-ruby">def extract_json_and_note(text)
  text = text.strip
  if text.start_with?("```")
    text = text.sub(/A```(?:json|JSON)?s*/, "").sub(/s*```z/, "").strip
  end

  start_idx = text.index("{")
  return [text, nil, false] if start_idx.nil?

  brace_count = 0
  in_string = false
  escape_next = false

  text[start_idx..].each_char.with_index do |char, idx|
    if escape_next
      escape_next = false
      next
    end
    case char
    when "\" then escape_next = true if in_string
    when '"'  then in_string = !in_string unless escape_next
    when "{"  then brace_count += 1 unless in_string
    when "}"
      brace_count -= 1 unless in_string
      if brace_count == 0
        end_idx = start_idx + idx
        json_text = text[start_idx..end_idx]
        note = text[(end_idx + 1)..].strip.presence
        return [json_text, note, true]
      end
    end
  end

  [text, nil, false]
end</code></pre>
<p><strong>Step 2: Normalize missing fields</strong> because Claude might omit arrays for edge cases:</p>
<pre><code class="language-ruby">def normalize_response!(result)
  result["score"] ||= 0
  result["level"] ||= "unknown"
  result["summary"] ||= "Analysis complete"
  result["items"] ||= []
  result["recommendations"] ||= []
  result["score"] = result["score"].to_i if result["score"].is_a?(String)
end</code></pre>
<p><strong>Step 3: Validate the structure</strong> because even after parsing, I couldn&#8217;t trust it:</p>
<pre><code class="language-ruby">def validate_response_structure!(result)
  required_keys = %w[score level summary items recommendations]
  missing_keys = required_keys - result.keys
  raise "Invalid response structure: missing keys #{missing_keys.join(', ')}" if missing_keys.any?

  score = result["score"]
  unless score.is_a?(Integer) &amp;&amp; score &gt;= 0 &amp;&amp; score &lt;= 100
    raise "Invalid score: must be integer 0-100, got #{score.inspect}"
  end

  %w[items recommendations].each do |key|
    unless result[key].is_a?(Array)
      raise "Invalid #{key}: expected array, got #{result[key].class}"
    end
  end
end</code></pre>
<p>All of this existed because I was asking an LLM to format its own output as JSON via natural language instructions. I was writing a fragile parser for a format the model was never <em>constrained</em> to produce.</p>
<h2>The Fix: Tool Use</h2>
<p>Anthropic&#8217;s tool use API (also called function calling) lets you define a JSON schema that Claude <em>must</em> conform to. Instead of asking Claude to output JSON, you tell the API: &#8220;call this function with these typed parameters.&#8221; Claude&#8217;s response is guaranteed to match the schema.</p>
<p>Here&#8217;s the schema definition:</p>
<pre><code class="language-ruby">ANALYSIS_TOOL = {
  name: "analyze",
  description: "Return the structured analysis results",
  input_schema: {
    type: "object",
    required: ["score", "level", "summary", "items", "recommendations"],
    properties: {
      score: { type: "integer", description: "Overall score from 0 (safe) to 100 (dangerous)" },
      level: { type: "string", enum: ["low", "medium", "high"] },
      summary: { type: "string", description: "Brief overall assessment" },
      items: {
        type: "array",
        items: {
          type: "object",
          required: ["title", "description", "severity"],
          properties: {
            title:       { type: "string" },
            description: { type: "string" },
            severity:    { type: "string", enum: ["low", "medium", "high"] }
          }
        }
      },
      recommendations: { type: "array", items: { type: "string" } }
    }
  }
}.freeze</code></pre>
<p>The API call adds two parameters:</p>
<pre><code class="language-ruby">response = client.messages.create(
  model: model,
  max_tokens: max_tokens,
  system: [{ type: "text", text: system_prompt }],
  messages: [{ role: "user", content: user_message }],
  tools: [ANALYSIS_TOOL],
  tool_choice: { type: "tool", name: "analyze" }
)</code></pre>
<p><code>tools:</code> defines the schema. <code>tool_choice:</code> with <code>type: "tool"</code> forces Claude to use it — no chance of returning prose instead.</p>
<p>Response extraction is three lines:</p>
<pre><code class="language-ruby">tool_block = response.content.find { |b| b.type.to_s == "tool_use" }
raise "No tool_use block in response" unless tool_block
result = tool_block.input.transform_keys(&amp;:to_s)</code></pre>
<p>That&#8217;s it. <code>tool_block.input</code> is already a parsed hash. No <code>JSON.parse</code>, no brace matching, no markdown stripping, no comma repair.</p>
<h2>The Result</h2>
<p><strong>Deleted:</strong> ~160 lines from the service, ~250 lines from tests..</p>
<p><strong>Added:</strong> ~30 lines for the schema definition, 2 parameters on the API call, 3 lines of response extraction.</p>
<p>The system prompt shrank too. The 28 lines of &#8220;YOU MUST RESPOND IN JSON&#8221; instructions disappeared entirely. The prompt now focuses on <em>what</em> to analyze, not <em>how</em> to format the output.</p>
<p>The user message went from <code>"Analyze this and respond with JSON only:"</code> to just <code>"Analyze this:"</code>.</p>
<h2>When Should You Use This?</h2>
<p>Any time you want structured output from an LLM. If you&#8217;re writing regex to fix JSON commas, building brace-matching parsers, or adding &#8220;RESPOND IN JSON ONLY&#8221; to your prompts: switch to tool use. The schema is self-documenting, the output is guaranteed valid, and you delete code instead of writing it.</p>
<p>The one caveat: tool use constrains the <em>structure</em> but not the <em>content</em>. Claude can still put whatever it wants in a string field. You still need to validate that a score is in a sensible range or that enum values match your expectations. But &#8220;validate the values&#8221; is a much smaller problem than &#8220;parse arbitrary text that might be JSON.&#8221;</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.adamzolo.com/claude-api-tool-use-vs-prompting/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Building Clausy: A Contract Analysis Tool with Rails 8 and Claude AI</title>
		<link>https://blog.adamzolo.com/building-clausy-a-contract-analysis-tool-with-rails-8-and-claude-ai/</link>
					<comments>https://blog.adamzolo.com/building-clausy-a-contract-analysis-tool-with-rails-8-and-claude-ai/#respond</comments>
		
		<dc:creator><![CDATA[Adam Zolo]]></dc:creator>
		<pubDate>Sun, 08 Feb 2026 21:07:19 +0000</pubDate>
				<category><![CDATA[AI]]></category>
		<category><![CDATA[Rails]]></category>
		<category><![CDATA[Ruby]]></category>
		<category><![CDATA[Web]]></category>
		<guid isPermaLink="false">https://blog.adamzolo.com/?p=1090</guid>

					<description><![CDATA[I just launched https://clausyapp.com, a web app that uses AI to analyze contracts and highlight potential issues. You upload a PDF/images or paste text, and Claude AI reads through it to find things like unlimited liability clauses, auto-renewal terms, or aggressive IP assignment language. Why I Built This I&#8217;ve signed a enough contracts over the&#8230;<p><a class="more-link" href="https://blog.adamzolo.com/building-clausy-a-contract-analysis-tool-with-rails-8-and-claude-ai/" title="Continue reading &#8216;Building Clausy: A Contract Analysis Tool with Rails 8 and Claude AI&#8217;">Continue reading <span class="meta-nav">&#8594;</span></a></p>]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">I just launched https://clausyapp.com, a web app that uses AI to analyze contracts and highlight potential issues. You upload a PDF/images or paste text, and Claude AI reads through it to find things like unlimited liability clauses, auto-renewal terms, or aggressive IP assignment language.</p>



<h2 class="wp-block-heading">Why I Built This</h2>



<p class="wp-block-paragraph">I&#8217;ve signed a enough contracts over the years, and I was never quite sure if I was missing something important buried in the legal language. I&#8217;d skim through them, but let&#8217;s be honest – I didn&#8217;t understand half of it. Getting a lawyer to review every contract is also just not something I&#8217;m going to do, unless it&#8217;s something really big.</p>



<p class="wp-block-paragraph">I figured: AI is pretty good at reading and understanding text now. And based on how many of these AI contract analysis tools are out there, it&#8217;s the new TODO app in the age of AI.</p>



<h2 class="wp-block-heading">The Stack</h2>



<p class="wp-block-paragraph">I went with a Rails 8 monolith because I wanted something I could ship quickly and maintain solo:</p>



<ul class="wp-block-list">
<li>Rails 8 with Hotwire (Turbo + Stimulus)</li>



<li>Anthropic&#8217;s Claude API</li>



<li>Solid Queue for background jobs with priority queues (paid users get faster processing)</li>



<li>Solid Cache for caching and rate limits</li>



<li>Stripe for subscriptions and billing</li>



<li>Tesseract OCR for extracting text from scanned images (JPG, PNG, WebP, HEIC)</li>



<li>Kamal for deployment</li>
</ul>



<p class="wp-block-paragraph">Everything runs in Docker containers. No separate frontend framework, no microservices. Just a straightforward Rails app that does one thing well.</p>



<h2 class="wp-block-heading">Technical Security Challenges</h2>



<ol class="wp-block-list">
<li><strong>File Processing Security</strong>
<ul class="wp-block-list">
<li>Magic byte validation – Don&#8217;t trust file extensions. I check the actual file signature to verify it&#8217;s really a PDF or DOCX.</li>



<li>Size limits – DOCX files are zip archives, so I enforce size limits before decompression to prevent zip bombs.</li>



<li>Immediate deletion – Original files are deleted right after text extraction. No long-term storage of sensitive documents.</li>



<li>Command injection prevention – Only use safe extraction tools, never shell out with user-provided filenames.</li>
</ul>
</li>



<li><strong>Server Access</strong>
<ul class="wp-block-list">
<li>Firewall at the provider level </li>



<li>Firewall at the node level (ufw)</li>



<li>ssh through certs only, limit access to specific IP&#8217;s</li>



<li>Cloudflare</li>
</ul>
</li>



<li><strong>Application </strong>Security
<ul class="wp-block-list">
<li>Devise authentication &#8211; Industry-standard auth framework</li>



<li>CSRF protection &#8211; Rails CSRF tokens on all POST/PUT/PATCH/DELETE requests</li>



<li>UUID-based URLs &#8211; Guest contracts use UUIDs (prevents enumeration attacks)</li>



<li>Rate Limits (Rack::Attack)</li>



<li>CSP Policy
<ul class="wp-block-list">
<li>No unsafe-eval &#8211; Prevents eval() attacks</li>



<li>Whitelisted script sources &#8211; Only self, HTTPS, Stripe, Cloudflare allowed</li>



<li>No object embeds &#8211; object_src :none blocks Flash/plugin attacks</li>



<li>Nonce-based scripts &#8211; Importmap scripts use session-based nonces</li>



<li>HTTPS enforced &#8211; All resources loaded over HTTPS</li>
</ul>
</li>



<li>Input Validation</li>
</ul>
</li>



<li>Fraud Prevention
<ul class="wp-block-list">
<li>Email history tracking &#8211; SHA256 email hashing &#8211; Email hashes stored, not plain emails</li>
</ul>
</li>



<li>Payment Security
<ul class="wp-block-list">
<li>Stripe webhook verification &#8211; Signature validation on all webhook events</li>



<li>No card storage &#8211; Stripe handles all payment details</li>
</ul>
</li>



<li>Secret Management
<ul class="wp-block-list">
<li>Rails credentials &#8211; All secrets in encrypted credentials.yml.enc</li>
</ul>
</li>



<li>XSS Prevention
<ul class="wp-block-list">
<li>Automatic HTML escaping</li>



<li>CSP headers &#8211; Content Security Policy blocks inline scripts</li>
</ul>
</li>



<li>Transport Security
<ul class="wp-block-list">
<li>HTTPS everywhere &#8211; All resources loaded over HTTPS</li>



<li>Secure cookies &#8211; Session cookies marked secure in production</li>



<li>HSTS headers &#8211; Forces HTTPS connections</li>
</ul>
</li>



<li>DoS Prevention
<ul class="wp-block-list">
<li>Job queues &#8211; Background processing prevents request timeouts</li>



<li>Priority queues &#8211; Paid users get separate high-priority queue</li>



<li>Rate limiting &#8211; Comprehensive rate limits across all endpoints</li>



<li>Query optimization &#8211; Indexed queries prevent slow lookups</li>
</ul>
</li>
</ol>



<h2 class="wp-block-heading">Try it</h2>



<p class="wp-block-paragraph">Demo (no signup): <a href="https://clausyapp.com/contracts/new?demo=hn">https://clausyapp.com/contracts/new?demo=hn</a></p>



<p class="wp-block-paragraph">Full app: <a href="https://clausyapp.com">https://clausyapp.com</a></p>



<p class="wp-block-paragraph">It&#8217;s not legal advice – I&#8217;m very explicit about that – but it can help you spot things you might want to ask a lawyer about.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.adamzolo.com/building-clausy-a-contract-analysis-tool-with-rails-8-and-claude-ai/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
