<?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: Efthimios</title>
    <description>The latest articles on DEV Community by Efthimios (@efousekis).</description>
    <link>https://dev.to/efousekis</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3912274%2Faefd155b-b2f3-42e7-91c4-3cd373e6153b.jpg</url>
      <title>DEV Community: Efthimios</title>
      <link>https://dev.to/efousekis</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/efousekis"/>
    <language>en</language>
    <item>
      <title>Keeping Strands agents honest in a household money app</title>
      <dc:creator>Efthimios</dc:creator>
      <pubDate>Mon, 14 Sep 2026 14:54:51 +0000</pubDate>
      <link>https://dev.to/efousekis/keeping-strands-agents-honest-in-a-household-money-app-17ld</link>
      <guid>https://dev.to/efousekis/keeping-strands-agents-honest-in-a-household-money-app-17ld</guid>
      <description>&lt;p&gt;Many household money leaks are small and quiet. A washing machine breaks a few months before its guarantee runs out, and the repair gets paid without anyone asking the seller. A free trial turns into a monthly charge. A card payment has no receipt by the time someone needs one. None of this is hard to spot. It is just easy to miss.&lt;/p&gt;

&lt;p&gt;An agent sounds like a good fit: read the records, point at what needs a decision. The catch is that a language model writing about guarantees and money can sound certain about things nobody checked, like a refund being owed or a deadline that does not exist. Hestia, built for the AWS Agents for Humans hackathon (Everyday Agents track), tries a narrow version: the model reads and points, plain Python works out the dates and amounts, and the household decides. Here is how its two Strands agents are built and what stops them from overstating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two agents, no write tools
&lt;/h2&gt;

&lt;p&gt;Both agents use Strands Agents 1.53.0 and call Claude Haiku 4.5 on Amazon Bedrock through the EU inference profile. Both run inside the same Lambda function, the one that handles POST routes, and neither has a tool that writes a record, prepares a notice or sends anything. The &lt;strong&gt;review agent&lt;/strong&gt; reads a household through four tools and writes a short briefing. The &lt;strong&gt;reading agent&lt;/strong&gt; has no tools and turns pasted text into proposed records. Both work inside a stored demo copy of a fictional household whose access lasts 30 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The review agent and its four read-only tools
&lt;/h2&gt;

&lt;p&gt;Each review builds a fresh &lt;code&gt;Agent&lt;/code&gt; with a &lt;code&gt;BedrockModel&lt;/code&gt; (&lt;code&gt;temperature=0.2&lt;/code&gt;, &lt;code&gt;streaming=False&lt;/code&gt;, 700 output tokens), the tools and a system prompt, then calls it once, asking it to use every tool once and write the briefing.&lt;/p&gt;

&lt;p&gt;The tools are plain Python closures over the loaded private copy and the review date:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Reads&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;review_repair_evidence(appliance_id)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;one appliance, its seller and any saved case&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;audit_subscriptions()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;trials, price changes and duplicates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;check_receipts_and_utilities()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;missing receipts and bills above baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;read_case_timeline()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;saved case status, next step and recent events&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Strands builds each schema from the signature and docstring, so wrapping is one line. From &lt;code&gt;src/hestia/agents/household_agent.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;tool_functions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Callable&lt;/span&gt;&lt;span class="p"&gt;[...,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;audit_subscriptions&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Inspect recorded recurring charges for trial end dates, price changes and overlaps.

        Amounts are recorded monthly charges, not measured waste or savings.
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;strands_tools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Wrap the workspace callables as Strands tools (schemas come from signatures and docs).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;func&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;tool_functions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sentence the model reads to pick a tool is the sentence a reviewer reads in the source. And because each tool closes over one private copy, no tool takes an argument that could reach another household.&lt;/p&gt;

&lt;p&gt;Tool outputs are clipped to 1600 characters. After the run, the trace is rebuilt by pairing &lt;code&gt;toolUse&lt;/code&gt; and &lt;code&gt;toolResult&lt;/code&gt; blocks in &lt;code&gt;agent.messages&lt;/code&gt;, and token usage comes from &lt;code&gt;result.metrics.accumulated_usage&lt;/code&gt;. Both are stored with the briefing.&lt;/p&gt;

&lt;p&gt;The system prompt says: use the tools, never state or imply entitlement to a refund, repair or amount, never invent deadlines, use only amounts and dates from tool outputs, do not draft the notice, and write under 180 words in three sections (What I checked, Decisions waiting for you, Suggested next step).&lt;/p&gt;

&lt;p&gt;A prompt is a request, not a control. So the code checks the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The guard withholds, it never rewrites
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;guard_narrative&lt;/code&gt; runs locally on the finished briefing. Any reason it returns withholds the whole briefing. It fires when the text:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;matches a banned pattern, such as "entitled to", "must refund", "deadline of" or "you are owed";&lt;/li&gt;
&lt;li&gt;names a euro amount (written with € or EUR) found in none of the outputs of the tools that ran;&lt;/li&gt;
&lt;li&gt;runs past 3200 characters or never mentions review.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An empty reply is withheld too.&lt;/p&gt;

&lt;p&gt;The amount check matters most. Tools write "EUR 185.00", a bare "13.99" or a count of cents, so the guard strips ISO dates from the tool outputs (a date must not lend its digits to an invented figure), normalises every number and also reads whole numbers as cents. From the tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;ha&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;guard_narrative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A €1,399.00 fee requires review.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1399 minor units&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;ha&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;guard_narrative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A €14.00 fee requires review.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;outputs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# 14 only appears in dates
&lt;/span&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;ha&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;guard_narrative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A €777.77 fee requires review.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;outputs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A withheld briefing is labelled as withheld, and the tool trace still shows, because the tool outputs come from the tools, not from the model's text. The guard does not patch text; a guard that rewrote model output would be a second author nobody reviewed.&lt;/p&gt;

&lt;p&gt;Its limits: it is a local pattern check, not Amazon Bedrock Guardrails. It does not check dates or amounts without a currency, and a wording its patterns miss would pass. The 180 word ceiling is only an instruction; the code enforces 3200 characters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tools-only fallback
&lt;/h2&gt;

&lt;p&gt;When the model is not available, the review route runs the same tools directly (the repair tool once for each appliance with a recorded repair) and returns their outputs with no narrative and a visible reason. It answers HTTP 200 either way.&lt;/p&gt;

&lt;p&gt;Reasons decided before any model call are &lt;code&gt;model_not_configured&lt;/code&gt;, &lt;code&gt;session_cap&lt;/code&gt;, &lt;code&gt;daily_cap&lt;/code&gt; and &lt;code&gt;budget_unconfirmed&lt;/code&gt;. Once the run starts, they are &lt;code&gt;model_timeout&lt;/code&gt; and &lt;code&gt;model_error:&amp;lt;ExceptionClass&amp;gt;&lt;/code&gt;. The timeout is a 20 second join on a worker thread, so the thread is not stopped and the attempt stays counted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reading agent turns text into proposals
&lt;/h2&gt;

&lt;p&gt;Typing a receipt is dull, so the household can paste a receipt, order email or statement excerpt of up to 6000 characters. A second &lt;code&gt;Agent&lt;/code&gt; reads it with &lt;code&gt;tools=[]&lt;/code&gt; and &lt;code&gt;temperature=0.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The prompt asks for &lt;code&gt;{"records": [...]}&lt;/code&gt; and nothing else: a field only when the text states it, no guessed date, price, email or model number, integer cents, at most 20 records. The code does not trust the reply. It parses from the first &lt;code&gt;{&lt;/code&gt; to the last &lt;code&gt;}&lt;/code&gt;, then &lt;code&gt;normalise_extracted&lt;/code&gt; keeps at most 20 records, three kinds (&lt;code&gt;appliance&lt;/code&gt;, &lt;code&gt;transaction&lt;/code&gt;, &lt;code&gt;subscription&lt;/code&gt;), their allowed keys and plain values only.&lt;/p&gt;

&lt;p&gt;The proposal is stored as a staged draft in the same form manual entry uses. Household records change only after the household reviews the draft, corrects what is wrong, and commits it with &lt;code&gt;confirmed: true&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The raw pasted text is not retained in Hestia's state. Hestia stores its SHA-256, byte count, proposed records, model id and usage. The same text pasted again replays the draft with no model call.&lt;/li&gt;
&lt;li&gt;With no deterministic reader to fall back to, the route fails closed: 503 when no model is configured or the daily budget is spent or unconfirmed, 429 when this copy has used its three readings or hit its draft limit, and 502 when the model times out, fails or returns an unreadable reply. These paths create no intake draft or household record. A 502 still stores an audit event and input hash, and may retain usage accounting.&lt;/li&gt;
&lt;li&gt;A failure raised by the model call, such as a Bedrock service error, hands the reading back. A timeout or unreadable reply stays counted. Either way, the unit taken from the daily budget stays spent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Extraction accuracy is unmeasured. The control is that a person confirms every fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bounding cost
&lt;/h2&gt;

&lt;p&gt;The demo is a public link with no login, so limits live on the server: up to 3 model-backed reviews per private copy, after which reviews remain tools-only; up to 3 counted pasted-text model attempts per private copy; 200 shared model-operation reservations per UTC day across all visitors; 700 output tokens per underlying model request; a 20-second application wait limit that returns a fallback or error but does not cancel an in-flight worker thread; and 40 application actions per private copy. One review may make several underlying model requests during its tool loop. Cost per call is unmeasured.&lt;/p&gt;

&lt;p&gt;The daily counter is one S3 object per day. The first call creates it with &lt;code&gt;If-None-Match: *&lt;/code&gt;; later calls write the new count with &lt;code&gt;If-Match&lt;/code&gt; on its ETag, making up to four attempts when a race is lost. If the count cannot be confirmed, the model is not called.&lt;/p&gt;

&lt;p&gt;One lesson from it: the Lambda roles have no &lt;code&gt;s3:ListBucket&lt;/code&gt;, and without it S3 answers a read of a missing key with AccessDenied, not NoSuchKey. The first release of the agent took that as an unconfirmed budget, and the live review silently fell back to tools only. The fix treats AccessDenied on that read as "maybe missing" and proves absence with the conditional create. A real permission failure still fails closed, and a test covers it with a fake S3 that imitates the missing permission.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deployment
&lt;/h2&gt;

&lt;p&gt;Two CloudFormation stacks run in eu-west-1: the web app on Amazon CloudFront over a private S3 bucket, and an API Gateway HTTP API over two Python 3.11 Lambda functions. The reader answers every GET and its role denies &lt;code&gt;bedrock:*&lt;/code&gt;. The writer handles POST routes and may invoke only the one Haiku inference profile and its foundation model. Both roles deny &lt;code&gt;ses:*&lt;/code&gt; and object deletes.&lt;/p&gt;

&lt;p&gt;The Lambda package installs pinned boto3 and Strands Agents versions, including &lt;code&gt;strands-agents==1.53.0&lt;/code&gt;, and checks inside the bundle that &lt;code&gt;Agent&lt;/code&gt;, &lt;code&gt;tool&lt;/code&gt; and &lt;code&gt;BedrockModel&lt;/code&gt; import. The backend ships from the CI artifact of the exact commit: an operator prepares a CloudFormation change set, reads it, then runs the execute step, which refuses a set with a &lt;code&gt;Remove&lt;/code&gt; action or &lt;code&gt;Replacement: True&lt;/code&gt; and then checks that both functions report the commit. The web app ships through a GitHub OIDC workflow that refuses to publish unless the live &lt;code&gt;/healthz&lt;/code&gt; names the approved backend commit with a live model and sending off.&lt;/p&gt;

&lt;p&gt;The live revision is &lt;code&gt;f58934736ec3e1a2a31995f2ecba82aaa8bd19ec&lt;/code&gt; for the web app and both functions. It retains the dynamic sample trial and corrects the public storage, model-transfer and no-send disclosures without changing the product flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;No CI test calls Bedrock. Both runners accept an &lt;code&gt;agent_factory&lt;/code&gt;, so tests inject fakes that answer well, answer badly, raise or hang. Storage tests use a fake S3 that enforces &lt;code&gt;If-None-Match&lt;/code&gt; and &lt;code&gt;If-Match&lt;/code&gt;. The suites cover tool schemas, tools-only mode, the guard, trace pairing, fallbacks, the counter, caps, fail-closed reading and replay. Playwright checks the briefing card and the paste tab, using fixture responses wherever a model reply is needed, and CI adds ruff and an 85% branch coverage gate.&lt;/p&gt;

&lt;p&gt;The deployed model is called by the production acceptance workflow, run by hand against the live URL. Runs for this release:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Main CI: &lt;a href="https://github.com/upgradedev/hestia-aws/actions/runs/34853104952" rel="noopener noreferrer"&gt;https://github.com/upgradedev/hestia-aws/actions/runs/34853104952&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Backend acceptance: &lt;a href="https://github.com/upgradedev/hestia-aws/actions/runs/34861828832" rel="noopener noreferrer"&gt;https://github.com/upgradedev/hestia-aws/actions/runs/34861828832&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Web app release: &lt;a href="https://github.com/upgradedev/hestia-aws/actions/runs/34861833141" rel="noopener noreferrer"&gt;https://github.com/upgradedev/hestia-aws/actions/runs/34861833141&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Paired acceptance: &lt;a href="https://github.com/upgradedev/hestia-aws/actions/runs/34862447716" rel="noopener noreferrer"&gt;https://github.com/upgradedev/hestia-aws/actions/runs/34862447716&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is not built
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No notice or email is sent. Approvals are recorded as a simulation, and IAM denies Amazon SES.&lt;/li&gt;
&lt;li&gt;Bank feeds, mailbox and retailer sync, and receipt photo OCR are not connected.&lt;/li&gt;
&lt;li&gt;Amazon Bedrock AgentCore and Bedrock Guardrails are not connected.&lt;/li&gt;
&lt;li&gt;Briefing quality, extraction accuracy, latency and cost are unmeasured.&lt;/li&gt;
&lt;li&gt;Independent human user acceptance testing is NOT_RUN.&lt;/li&gt;
&lt;li&gt;The tools flag what needs review; they do not decide eligibility. EU Directive 2019/771 is a general reference only, and eligibility needs its own review.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Live demo, no login, 30 minutes of access to a stored fictional demo space: &lt;a href="https://drusjukc9d4oc.cloudfront.net/" rel="noopener noreferrer"&gt;https://drusjukc9d4oc.cloudfront.net/&lt;/a&gt;. The workspace is not automatically deleted, so use only fictional, non-sensitive details.&lt;/li&gt;
&lt;li&gt;Code, public under the MIT license: &lt;a href="https://github.com/upgradedev/hestia-aws" rel="noopener noreferrer"&gt;https://github.com/upgradedev/hestia-aws&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Demo video: &lt;a href="https://youtu.be/k6SHxtytY80" rel="noopener noreferrer"&gt;https://youtu.be/k6SHxtytY80&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>ai</category>
      <category>python</category>
      <category>agents</category>
    </item>
  </channel>
</rss>
