<?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>Sat, 09 May 2026 14:00:17 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.9.4</generator>
	<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>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>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>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>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>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>Demo (no signup): <a href="https://clausyapp.com/contracts/new?demo=hn">https://clausyapp.com/contracts/new?demo=hn</a></p>



<p>Full app: <a href="https://clausyapp.com">https://clausyapp.com</a></p>



<p>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>
