Building an AI Agent That Knows Its Own Cost: Open-Sourcing the Cognitive Work Architecture

Python code showing AI agent 5-layer output guardrail pipeline with budget cost tracking, cache-first model calls, and per-request Firestore spend accumulation
Abstract blueprint visualization of a 5-layer AI agent output guardrail pipeline with amber governance gate and data stream flowing through translucent filter layers on dark background

Most AI agents are built to be impressive. This one was built to be accountable.

There’s a gap in the AI agent ecosystem that nobody talks about at conferences.

Every tutorial shows you how to build an agent that works. Few show you how to build one that works within a budget, enforces its own behavioral rules in code, and degrades gracefully when something goes wrong instead of showing your visitors an error page.

We’ve spent the past year building and deploying AI agents for enterprise clients. Along the way, we developed an internal framework we call the Cognitive Work Architecture (CWA), a set of patterns for building agents that operate with the same governance, cost discipline, and accountability you’d expect from any production enterprise system.

Today, we’re open-sourcing the first agent built on this architecture: the Site Sales Agent.

Not a demo. Not a proof of concept. The actual production code running on our website, handling real conversations, tracking its own cost to six decimal places on every single request.

GitHub: Cognitive-work-architecture/sales-agent

What the Site Sales Agent Actually Does

The agent sits on our website as a chat widget. When a visitor asks about our services, it responds with the depth and nuance of a senior sales advisor — not the enthusiasm of a chatbot desperately trying to book a meeting.

Here’s what happens on every request:

  1. The visitor’s message arrives with their conversation history
  2. Cloudflare Turnstile verifies the request isn’t from a bot
  3. The monthly budget is checked, if exhausted, a graceful fallback is served
  4. The session prompt count is checked, if exceeded, a human handoff is offered
  5. The model generates a response using cached site content
  6. Five layers of output processing enforce behavioral rules
  7. The actual token cost is calculated and accumulated against the monthly budget
  8. The clean response is returned with optional booking CTA

Every step in that flow has a failure mode, and every failure mode has a handler. The visitor never sees an error.

The Three Pillars

The architecture rests on three pillars that we believe every production AI agent needs:

1. Budget Governance

2. Behavioral Guardrails

3. Graceful Degradation

Let’s look at each one.

Pillar 1: Budget Governance

This is the pillar most agent builders skip entirely.

Here’s the typical AI agent cost management strategy: deploy the agent, hope the token costs stay reasonable, and find out what you actually spent when the invoice arrives 30 days later.

That’s not engineering. That’s gambling.

Per-Request Cost Tracking

Our agent calculates the exact cost of every single request using the token counts from the model’s response metadata:

RATE_INPUT_CACHED = 0.15 / 1_000_000
RATE_INPUT_STD    = 1.50 / 1_000_000
RATE_OUTPUT       = 7.50 / 1_000_000

um         = resp.usage_metadata
cached     = getattr(um, "cached_content_token_count", 0) or 0
prompt_tok = getattr(um, "prompt_token_count", 0) or 0
out_tok    = getattr(um, "candidates_token_count", 0) or 0
non_cached = max(prompt_tok - cached, 0)
cost       = (cached * RATE_INPUT_CACHED + non_cached * RATE_INPUT_STD + out_tok * RATE_OUTPUT)

budget_ref.update({"CurrentSpendUSD": firestore.Increment(cost)})

Notice the distinction between cached and non-cached input tokens. When the context cache is active, site content tokens are billed at 1/10th the standard rate. When the cache is missing or expired, those same tokens are billed at the full rate. The agent tracks both separately because they represent fundamentally different cost profiles.

The cost is accumulated in Firestore using firestore.Increment() — an atomic operation that prevents race conditions even under concurrent requests.

Monthly Budget Lifecycle

The budget isn’t just tracked, it’s governed through a four-stage lifecycle:

Track → Every request calculates and accumulates its cost.

Monitor → A scheduled job runs every four hours, checking spend against two thresholds:

  • 80% (DegradeThreshold): A warning alert fires via webhook — Slack, Teams, PagerDuty, whatever you’ve configured. The alert fires exactly once, not every four hours. Alert deduplication is built in through a boolean flag.
  • 100% (MonthlyLimit): A critical alert fires, and the service tier is set to “Degraded.” The agent stops calling the model and serves a fallback CTA instead.

Enforce → The agent reads the current tier on every request. When degraded, visitors see a professional message with a direct booking link — not an error.

Reset → On the 1st of each month, a scheduled job zeros the spend counters and clears the alert flags. Importantly, it preserves any custom budget limits. If someone manually raised the MonthlyLimitUSD mid-month, the reset doesn’t stomp that configuration — it only clears the counters.

Auto-Recovery

One detail we’re particularly proud of: the system self-heals. If the budget monitor finds that spend has dropped below the monthly limit — because someone manually increased the budget, or because a new billing period started — it automatically restores the service tier to “Full” without any human intervention.

Most budget systems require a manual reset to un-degrade. This one doesn’t.

What It Costs in Practice

At current Gemini 2.5 Flash Lite pricing, with context caching:

ScenarioMonthly Cost
1,000 conversations, 4 turns average, with cache~$2.50
1,000 conversations, 4 turns average, without cache~$18.00
Savings from caching~85%

The default budget is set to $10/month — more than enough for a site-level sales agent. The degrade threshold sits at $8, giving you a $2 warning buffer before the agent stops calling the model.

Pillar 2: Behavioral Guardrails

“Prompt = Suggestion. Code = Law.”

This is the thesis of the entire architecture.

Every AI agent has a system prompt that tells the model how to behave. Our system prompt is detailed — it defines a 4-state conversation machine, handles low-energy replies, prevents premature booking suggestions, and forbids URL generation.

But here’s what we learned in production: models don’t always follow prompts.

Not because they’re broken. Because they’re probabilistic. A large language model will follow your instructions most of the time. But “most of the time” isn’t good enough when a model that ignores your “don’t push for a meeting on the first message” instruction ends up feeling like a desperate chatbot instead of a senior advisor.

So we built a second layer of enforcement, in code.

The 5-Layer Output Pipeline

Every model response passes through five processing layers before reaching the visitor:

Layer 1: Smart Truncation

def smart_truncate(text, limit=MAX_ANSWER_CHARS):
    text = (text or "").strip()
    if len(text) <= limit:
        return text
    cut = text[:limit]
    for sep in [". ", "! ", "? "]:
        idx = cut.rfind(sep)
        if idx > limit * 0.6:
            return cut[:idx + 1].strip()
    return cut.rsplit(" ", 1)[0].strip() + "…"

The model is told to keep responses concise. But sometimes it generates a 1,200-character wall of text for a small chat window. This function caps the output at 700 characters, but it doesn’t cut mid-sentence. It finds the last sentence-ending punctuation mark in the safe zone (after 60% of the content has been preserved) and cuts there.

If no sentence boundary is found, it falls back to the last word boundary and adds an ellipsis.

Layer 2: Premature CTA Language Removal

def strip_premature_cta_language(text, turn_count, min_turns=MIN_TURNS_BEFORE_CTA):
    if turn_count >= min_turns:
        return text
    patterns = [
        r"Would you like to (schedule|book|set up).*?\?",
        r"Ready to (schedule|book|set up).*?\?",
        r"Let'?s (schedule|book|set up).*?\.",
        r"Shall we (schedule|book|set up).*?\?",
    ]
    for p in patterns:
        text = re.sub(p, "", text, flags=re.IGNORECASE)
    return text.strip()

The system prompt says: “Don’t suggest a meeting before the visitor has had at least two substantive exchanges.” The model usually complies. When it doesn’t, this function catches phrases like “Would you like to schedule a call?” and silently removes them if we haven’t passed the turn gate (MIN_TURNS_BEFORE_CTA = 3).

Once the turn count is at or above the minimum, this function does nothing — it’s a gate, not a permanent filter.

Layer 3: Booking Marker Extraction

The system prompt tells the model to emit [BOOK_CTA] as a marker when it detects buying intent. The code extracts this marker (retaining it as a signal for whether to render the booking button) and removes it from the display text so the visitor never sees the raw marker.

Layer 4: URL Stripping

def strip_urls(text):
    t = text or ""
    had_url = bool(re.search(r'https?://\S+', t))
    t = re.sub(r'https?://\S+', '', t)
    t = re.sub(r'\s*(here|below|at)\s*:?\s*$', '', t, flags=re.IGNORECASE)
    t = re.sub(r'\s{2,}', ' ', t).strip()
    return t, had_url

The model is explicitly told: “Never write a URL or booking link.” But occasionally, it generates one anyway — usually a hallucinated URL that doesn’t even exist.

This function strips all URLs from the output and also cleans up dangling text like “…click here:” or “…visit the link below:” that would read awkwardly after the URL is removed.

The function returns a boolean indicating whether a URL was found. If the model tried to output a URL, that’s treated as an additional signal for booking intent — the model was probably trying to share a booking link.

Layer 5: CTA Gate Check

show_cta = (
    (has_marker or had_url or visitor_wants_booking or new_count >= MAX_PROMPTS_PER_SESSION)
    and new_count >= MIN_TURNS_BEFORE_CTA
)

The final gate. Even if every other signal says “show the booking button,” it will not render unless new_count >= MIN_TURNS_BEFORE_CTA. This is the hard override.

The prompt says “don’t push meetings early.” The code makes it impossible.

The Conversation State Machine

Beneath the output pipeline, the agent’s behavior is governed by a 4-state conversation machine defined in the system prompt:

StateNameBehaviorTransition Rule
1DiscoveryAsk one question about their challengeEnter once, never return. Skip if visitor opens with a specific question
2AcknowledgeValidate why their challenge is hard — one sentenceMove to Solution after one clarifier (if needed)
3SolutionMatch problem to capability. Cite proof pointsPrimary operating state. Go deeper, not wider. Never repeat.
4IntentBooking CTAOnly on explicit buying signals. [BOOK_CTA] emitted once maximum

The critical design decision is the low-energy reply handler. When a visitor responds with “ok,” “sure,” “cool,” or “thanks” after receiving a solution, most chatbots interpret that as engagement and push for a meeting.

It’s not engagement. It’s politeness.

The system prompt explicitly tells the model: this is NOT buying intent. Instead of moving to State 4 (booking), the model should offer one additional insight — a related proof point, a different angle, or a qualifying question. If the visitor gives two consecutive low-energy replies, the model offers a graceful exit.

This is the difference between an agent that feels like a senior advisor and one that feels like a used car salesman.

Pillar 3: Graceful Degradation

The agent has a clear hierarchy of degradation, and every level serves the visitor professionally:

ConditionWhat the Visitor Sees
Everything worksFull agent response with optional booking CTA
Cache missingFull agent response (costs more, but visitor doesn’t notice)
Model call fails“I’m having a brief issue. Let’s connect you with our team directly.” + booking CTA
Session prompt cap reachedLead capture form + booking CTA
Monthly budget exhaustedProfessional message + direct booking link

The visitor never sees a stack trace, an error code, or a blank response. Every failure mode routes to a human handoff.

Error Handling Is Not an Afterthought

The error handling in this agent is deliberately designed to communicate cost impact to the engineering team while protecting the visitor experience:

except Exception as e:
    return ({
        "reply": "I'm having a brief issue. Let's connect you with our team directly.",
        "cta_html": consultation_cta(),
        "tier": "Error",
        "model_called": False,
        "error_detail": str(e)[:200],
    }, 200, headers)

The response returns HTTP 200 — not 500. The visitor gets a professional message. The error_detail field is truncated to 200 characters so it’s useful for debugging without ever leaking project IDs, paths, or credentials.

In the cache refresh module, the error log message literally says: “agent falls back to inline (full-price) context.” That one sentence tells the on-call engineer exactly what’s at stake — not just “something failed,” but “something failed and it’s costing you 10x more per request until you fix it.”

Cost Optimization: Context Caching

The agent’s knowledge base is a ~8,000-token document containing service descriptions, proof points, competitive positioning, and objection handling scripts. Without caching, this document is sent with every single request at full input token pricing.

Context caching changes the economics entirely.

How It Works

A scheduled Cloud Function runs daily and creates a Gemini context cache with a 25-hour TTL (providing a 1-hour buffer beyond the daily refresh cycle). The cache includes both the system prompt and the site content. The cache name is stored in Firestore.

At runtime, the agent reads the cache name from Firestore and passes it to the model config. The site content tokens are now billed at the cached rate 1/10th of the standard price.

The Fallback

If the cache is missing (expired, creation failed, first deployment), the agent falls back to inline context. It constructs the system instruction with the site content embedded directly:

cache_name = get_cache_name()
if cache_name:
    cfg = types.GenerateContentConfig(
        max_output_tokens=MAX_OUTPUT_TOKENS,
        temperature=0.3,
        cached_content=cache_name,
    )
else:
    cfg = types.GenerateContentConfig(
        max_output_tokens=MAX_OUTPUT_TOKENS,
        temperature=0.3,
        system_instruction=f"{SYSTEM_PROMPT}\n\nSITE CONTENT:\n{SITE_CONTEXT}",
    )
resp = client.models.generate_content(model=MODEL, contents=contents, config=cfg)

The agent works in both modes. It just costs more without the cache. And because the budget governance tracks cached vs. non-cached tokens separately, the cost difference is visible in the per-request telemetry.

Cache Name in Firestore, Not in Code

The cache name is stored in Firestore, not hardcoded in the agent’s configuration. This enables zero-downtime cache rotation:

  1. New cache created with updated site content
  2. Firestore config updated atomically
  3. Next agent request picks up the new cache
  4. Old cache expires naturally

No redeployment required.

Security

Bot Protection

Every request includes a Cloudflare Turnstile token. The server verifies it before processing:

def verify_turnstile(token, remoteip=None):
    if not TURNSTILE_SECRET:
        return True  # skip in dev
    try:
        r = _rq.post(
            "https://challenges.cloudflare.com/turnstile/v0/siteverify",
            data={"secret": TURNSTILE_SECRET, "response": token, "remoteip": remoteip},
            timeout=10,
        )
        return bool(r.json().get("success", False))
    except Exception:
        return False  # Fail closed

Note: the function fails closed. If Turnstile verification fails for any reason (network error, timeout, malformed response), the request is rejected. This is a deliberate security decision — we’d rather occasionally block a legitimate visitor than allow bots to drain our budget.

No Conversation Storage on the Server

The server is stateless regarding conversation content. History is managed on the client side and sent with each request. The server stores only metadata — prompt count, budget spend, lead captures. Never conversation text.

This means there’s no server-side PII to protect, breach, or comply with — the client controls its own context.

The Widget

The frontend is a single HTML file. No React. No npm. No build step. No CDN dependency. Paste it before </body> on any page.

This is a deliberate architectural choice. A React-based chat widget would add 40-150KB of framework code to every page load. Our widget is ~3KB. At scale, that’s bandwidth cost you’re paying on every page load for every visitor — not just the ones who open the chat.

Rotating Teasers

Instead of a static “Chat with us” button, the widget shows a random teaser bubble after 4 seconds — once per browser session. Each teaser surfaces a specific proof point or pain point:

🤖 Hi! Most AI agents run without cost controls. Ours don’t. Ask me why.

Clicking the teaser opens the chat AND sends the associated question automatically. The visitor gets value immediately — no “How can I help you today?” preamble.

Deep Linking

Any <a href="#chat"> link on the page opens the widget. This means you can put “Talk to our AI advisor” links in your navigation, inline content, CTAs — anywhere. They all open the same chat panel.

Prompt Duplication: An Architectural Trade-off

The system prompt appears in three files: the core agent, the CLI cache setup script, and the scheduled cache refresh function.

This is intentional, and it’s worth explaining why we chose duplication over abstraction.

Each module deploys independently as a separate Cloud Function. If we extracted the prompt into a shared import, updating the prompt would require redeploying all three functions simultaneously. A version mismatch between the cache (created with prompt version A) and the agent (running prompt version B) would produce inconsistent behavior.

The alternative — storing the prompt in Firestore and loading it at runtime — adds a network dependency to cold start and introduces a new failure mode.

We chose the trade-off we can manage: manual synchronization of three files, with a comment in each one that says "MUST be identical." It’s not elegant. It’s correct.

What’s in the Repo

ModulePurpose
sales-agent/main.pyCore conversational agent with 5-layer guardrail pipeline
sales-agent/cache_setup.pyCLI tool for manual cache creation
capture-lead/main.pyLead capture with email validation and CRM webhook
refresh-cache/main.pyScheduled context cache refresh
monitor-budget/main.pyBudget threshold monitor (runs every 4h)
reset-budget/main.pyMonthly budget reset
widget/widget.htmlSelf-contained chat widget
scripts/GCP API enablement and Firestore setup
site_content_template.txtAgent knowledge base template with working example

Every file is annotated with inline comments explaining the architectural decision behind each section — including the failure mode it prevents.

What This Architecture Is Not

This is not a general-purpose agent framework. It’s not LangChain. It’s not trying to be.

It’s a set of patterns for building specific, bounded, production-grade AI agents that operate within defined constraints. The patterns are:

  • Budget governance with per-request cost tracking and multi-tier enforcement
  • Behavioral guardrails enforced in code, not just in prompts
  • Graceful degradation that never shows a visitor an error
  • Cost optimization through context caching with automatic fallback
  • Security through Turnstile verification, input validation, and output scrubbing

If you’re building a research prototype or a hackathon demo, you don’t need any of this.

If you’re building an agent that will run in production, handle real conversations, and show up on a real invoice — you need all of it.

What’s Next

This is the first agent in the Cognitive Work Architecture repo. The architecture patterns — budget governance, behavioral guardrails, graceful degradation — are designed to be applied to any agent, not just sales.

The inline comments in the code document every decision with the failure mode it prevents. We encourage you to read them, disagree with them, and build on them.

The architecture is the moat. The code is open.

GitHub: Cognitive-work-architecture/sales-agent


This post is part of the Cognitive Work Architecture series — exploring how to build AI agents that operate with the same governance, accountability, and cost discipline you’d expect from any production enterprise system.

Alex Cuciurean is the founder of Sky Alleys, where the Cognitive Work Architecture powers enterprise AI deployments across cloud, manufacturing, and advisory practices.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.