Agent Template

Freelancer Tax Q&A Helper

A friendly, plain-language tax-question helper for freelancers and independent contractors. Ask about deductions, quarterly estimated taxes, and self-employment tax. The agent cites the general rule, explains steps or quick calculations when helpful, and always ends with a short reminder that this isn't professional tax advice.

taxes freelancer deductions self-employment estimated taxes
ozzo Jun 18, 2026 1 use

Preview Mode

This is a preview with sample data. The template uses placeholders like which will be replaced with actual agent data.

About This Template

Freelancer Tax Q&A Helper is a browser-executable AI agent template built on AgentOp. It runs entirely in the browser using Python (via Pyodide) and can be deployed without a server — just download the generated HTML file and open it locally or host it anywhere.

Topics taxes freelancer deductions self-employment estimated taxes
Template Preview

Template Metadata

Slug
freelancer-tax-qa-helper
Created By
ozzo
Created
Jun 18, 2026
Usage Count
1

Tags

taxes freelancer deductions self-employment estimated taxes

Code Statistics

HTML Lines
65
CSS Lines
45
JS Lines
199
Python Lines
121

Source Code

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>{{ agent_name }}</title>
  <style>{{ css_code }}</style>
</head>
<body>
  <div class="app">
    <header class="app-header">
      <div class="title-wrap">
        <h1>🧾 {{ agent_name }}</h1>
        <p class="subtitle">{{ description }}</p>
      </div>
      <div class="controls">
        <label for="jurisdiction">Tax jurisdiction</label>
        <select id="jurisdiction" aria-label="Select tax jurisdiction">
          <option value="US" selected>United States (default)</option>
          <option value="UK">United Kingdom</option>
          <option value="Canada">Canada</option>
          <option value="Australia">Australia</option>
          <option value="EU">European Union</option>
          <option value="Other">Other / not listed</option>
        </select>
        <button id="clear-btn" class="secondary" type="button" title="Clear conversation">Clear</button>
      </div>
    </header>

    <main class="chat-layout">
      <section class="chat-panel" aria-live="polite" aria-label="Freelancer tax chat">
        <div id="results-container" class="messages">
          <div id="empty-state" class="empty">
            <h2>Ask about freelancer taxes</h2>
            <ul>
              <li>What counts as an ordinary and necessary business expense?</li>
              <li>How to estimate quarterly payments and avoid penalties</li>
              <li>Home office, vehicle, and equipment deductions</li>
              <li>Self-employment tax basics and quick math examples</li>
            </ul>
            <div class="chips" id="example-chips"></div>
          </div>
        </div>
      </section>

      <footer class="composer">
        <div class="input-wrap">
          <textarea id="user-input" rows="1" placeholder="Ask a freelancer tax question..." aria-label="Ask a freelancer tax question"></textarea>
          <button id="send-btn" class="primary" type="button">Send</button>
        </div>
        <p class="helper-text">Friendly guidance only. For complex or high-stakes issues, consult a tax professional.</p>
      </footer>
    </main>

    <div id="overlay" class="overlay hidden" aria-hidden="true">
      <div class="spinner" aria-label="Loading"></div>
      <div class="overlay-text" id="overlay-text">Initializing Python…</div>
    </div>

    <div id="model-hint" class="model-hint hidden">Load a local model using the selector at the top to start chatting, or switch to a cloud provider.</div>
  </div>

  <script>{{ js_code }}</script>
</body>
</html>
:root{--bg:#0b1520;--panel:#101c2a;--muted:#96a4b5;--text:#e8eef5;--accent:#4cc38a;--accent-strong:#3db175;--warning:#ffb74d;--danger:#ff6b6b;--ring:0 0 0 3px rgba(76,195,138,0.35);--radius:14px;--shadow:0 10px 30px rgba(0,0,0,0.35);--font:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:linear-gradient(180deg,#0b1520, #0a1926);color:var(--text);font-family:var(--font)}
.app{max-width:1100px;margin:0 auto;padding:18px 18px 28px}
.app-header{background:rgba(16,28,42,0.8);backdrop-filter:saturate(140%) blur(10px);border:1px solid rgba(255,255,255,0.06);border-radius:var(--radius);padding:16px 18px;display:flex;flex-wrap:wrap;gap:14px;align-items:center;box-shadow:var(--shadow)}
.title-wrap{flex:1 1 360px}
.title-wrap h1{margin:0;font-size:22px;letter-spacing:0.2px}
.subtitle{margin:6px 0 0 0;color:var(--muted);font-size:14px}
.controls{display:flex;gap:10px;align-items:center}
.controls label{font-size:12px;color:var(--muted)}
select,button,textarea{font-family:var(--font);font-size:14px;border-radius:10px;border:1px solid rgba(255,255,255,0.08);background:#0f2234;color:var(--text)}
select{padding:8px 10px}
button{cursor:pointer;padding:10px 14px}
button.primary{background:var(--accent);color:#0a1a27;border:none}
button.primary:hover{background:var(--accent-strong)}
button.secondary{background:#0f2234}
button:disabled{opacity:0.6;cursor:not-allowed;filter:grayscale(20%)}
button:focus-visible,select:focus-visible,textarea:focus-visible{outline:none;box-shadow:var(--ring)}
.chat-layout{display:grid;grid-template-rows:1fr auto;margin-top:16px;gap:10px}
.chat-panel{background:var(--panel);border:1px solid rgba(255,255,255,0.06);border-radius:var(--radius);padding:16px;min-height:380px;max-height:62vh;overflow:auto;box-shadow:var(--shadow)}
.messages{display:flex;flex-direction:column;gap:14px}
.msg{display:flex;gap:12px;align-items:flex-start}
.msg .bubble{padding:12px 14px;border-radius:12px;max-width:82%;line-height:1.45;white-space:pre-wrap;word-wrap:break-word}
.msg.user .bubble{background:#143652;border:1px solid rgba(255,255,255,0.05)}
.msg.assistant .bubble{background:#0e2a1f;border:1px solid rgba(76,195,138,0.25)}
.role{font-size:12px;color:var(--muted);min-width:70px;text-align:right;padding-top:4px}
.empty{opacity:0.9;text-align:left}
.empty h2{margin:6px 0 8px 0;font-size:18px}
.empty ul{margin:8px 0 12px 18px;color:var(--muted)}
.chips{display:flex;flex-wrap:wrap;gap:8px}
.chip{background:#0f2234;border:1px solid rgba(255,255,255,0.08);border-radius:999px;padding:8px 12px;font-size:13px;color:#dfe8f2;cursor:pointer}
.chip:hover{border-color:rgba(76,195,138,0.5);color:#fff}
.composer{position:relative}
.input-wrap{display:flex;gap:10px;align-items:flex-end;background:var(--panel);border:1px solid rgba(255,255,255,0.06);border-radius:var(--radius);padding:10px 10px;box-shadow:var(--shadow)}
textarea#user-input{flex:1;resize:vertical;min-height:38px;max-height:160px;padding:10px 12px;background:#0f2234;border:1px solid rgba(255,255,255,0.08)}
.helper-text{color:var(--muted);font-size:12px;margin:8px 4px 0}
.overlay{position:fixed;inset:0;background:rgba(5,10,16,0.6);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;z-index:50}
.spinner{width:36px;height:36px;border-radius:50%;border:3px solid rgba(255,255,255,0.15);border-top-color:var(--accent);animation:spin 1s linear infinite}
.overlay-text{color:#cdd8e5;font-size:14px}
@keyframes spin{to{transform:rotate(360deg)}}
.model-hint{margin-top:10px;background:#2a1a0a;color:#ffd9a8;border:1px solid rgba(255,183,77,0.5);border-radius:10px;padding:10px 12px}
.hidden{display:none !important}
@media (max-width:1200px){.msg .bubble{max-width:88%}}
@media (max-width:768px){.app{padding:12px}.title-wrap h1{font-size:20px}.controls{width:100%;justify-content:flex-start;flex-wrap:wrap}.controls label{display:none}.chat-panel{max-height:58vh}}
(function(){
  const qs = (s, el=document)=>el.querySelector(s);
  const qsa = (s, el=document)=>Array.from(el.querySelectorAll(s));
  const results = qs('#results-container');
  const inputEl = qs('#user-input');
  const sendBtn = qs('#send-btn');
  const clearBtn = qs('#clear-btn');
  const overlay = qs('#overlay');
  const overlayText = qs('#overlay-text');
  const modelHint = qs('#model-hint');
  const jurisdictionEl = qs('#jurisdiction');
  const exampleChips = qs('#example-chips');
  let lastAssistantBubble = null;

  function escapeHtml(str){
    if(str === null || str === undefined) return '';
    return String(str)
      .replace(/&/g,'&amp;')
      .replace(/</g,'&lt;')
      .replace(/>/g,'&gt;')
      .replace(/"/g,'&quot;')
      .replace(/'/g,'&#39;');
  }

  function setProcessing(on){
    if(on){
      overlay.classList.remove('hidden');
      overlayText.textContent = 'Thinking…';
      sendBtn.disabled = true;
      inputEl.disabled = true;
    } else {
      overlay.classList.add('hidden');
      sendBtn.disabled = false;
      inputEl.disabled = false;
      inputEl.focus();
    }
  }

  function hideEmpty(){ const es = qs('#empty-state'); if(es) es.remove(); }

  function msgTemplate(role, content){
    const wrap = document.createElement('div');
    wrap.className = `msg ${role}`;
    const roleEl = document.createElement('div');
    roleEl.className = 'role';
    roleEl.textContent = role === 'user' ? 'You' : 'Assistant';
    const bubble = document.createElement('div');
    bubble.className = 'bubble';
    bubble.innerHTML = escapeHtml(content);
    wrap.appendChild(roleEl); wrap.appendChild(bubble);
    return {wrap, bubble};
  }

  window.addMessage = function(type, content){
    hideEmpty();
    // Streaming support for local models
    if(type === 'assistant'){
      if(lastAssistantBubble && lastAssistantBubble.isConnected){
        lastAssistantBubble.innerHTML += escapeHtml(content);
        results.scrollTop = results.scrollHeight;
        return lastAssistantBubble.parentElement;
      } else {
        const m = msgTemplate('assistant', content);
        lastAssistantBubble = m.bubble;
        results.appendChild(m.wrap);
        results.scrollTop = results.scrollHeight;
        return m.wrap;
      }
    }
    const m = msgTemplate(type === 'user' ? 'user' : 'assistant', content);
    if(type !== 'assistant') lastAssistantBubble = null;
    results.appendChild(m.wrap);
    results.scrollTop = results.scrollHeight;
    return m.wrap;
  }

  function addExamples(){
    const ex = [
      'Can I deduct my home office if I also use a coworking space sometimes?',
      'How do I figure my quarterly estimated payments this year?',
      'Do I owe self-employment tax on side-gig income if I have a W-2 job?',
      'What records should I keep to support software and equipment deductions?'
    ];
    ex.forEach(t=>{
      const c = document.createElement('button');
      c.className = 'chip';
      c.type = 'button';
      c.textContent = t;
      c.addEventListener('click',()=>{ inputEl.value = t; inputEl.focus(); });
      exampleChips.appendChild(c);
    });
  }

  function updateModelStatus(){
    try{
      const isLocal = (typeof window.PROVIDER !== 'undefined') && String(window.PROVIDER).toLowerCase().includes('local');
      const loaded = window.agentManager && window.agentManager.isLoaded;
      if(isLocal && !loaded){
        modelHint.classList.remove('hidden');
        sendBtn.disabled = true; inputEl.disabled = false; // allow typing but disable send until loaded
      } else {
        modelHint.classList.add('hidden');
        sendBtn.disabled = false; inputEl.disabled = false;
      }
    }catch(e){ /* ignore */ }
  }

  async function ensureReady(){
    return new Promise((resolve)=>{
      if(window.pyodideReady){ resolve(); return; }
      document.addEventListener('pyodide-ready', ()=> resolve(), {once:true});
    });
  }

  async function send(){
    const q = (inputEl.value || '').trim();
    if(!q) return;
    updateModelStatus();
    const isLocal = (typeof window.PROVIDER !== 'undefined') && String(window.PROVIDER).toLowerCase().includes('local');
    const loaded = window.agentManager && window.agentManager.isLoaded;
    if(isLocal && !loaded){ modelHint.classList.remove('hidden'); return; }

    hideEmpty();
    window.addMessage('user', q);
    setProcessing(true);
    try{
      window.pyodide.globals.set('user_query', q);
      window.pyodide.globals.set('ui_jurisdiction', jurisdictionEl.value || 'US');
      const py = `
result = await process_user_query(user_query, ui_jurisdiction)
result
      `;
      const answer = await window.pyodide.runPythonAsync(py);
      window.addMessage('assistant', String(answer||''));
    }catch(err){
      console.error(err);
      window.addMessage('assistant', 'Sorry, something went wrong while generating a response. Please try again.');
    }finally{
      setProcessing(false);
      inputEl.value = '';
    }
  }

  async function clearChat(){
    try{
      await window.pyodide.runPythonAsync('_reset_state()');
    }catch(e){}
    results.innerHTML = '';
    const empty = document.createElement('div');
    empty.id = 'empty-state'; empty.className = 'empty';
    empty.innerHTML = `
      <h2>Ask about freelancer taxes</h2>
      <ul>
        <li>What counts as an ordinary and necessary business expense?</li>
        <li>How to estimate quarterly payments and avoid penalties</li>
        <li>Home office, vehicle, and equipment deductions</li>
        <li>Self-employment tax basics and quick math examples</li>
      </ul>
      <div class="chips" id="example-chips"></div>
    `;
    results.appendChild(empty);
    lastAssistantBubble = null;
    // Reattach example chips
    const chipsHost = qs('#example-chips');
    if(chipsHost){
      exampleChips.innerHTML='';
      chipsHost.replaceWith(exampleChips);
      addExamples();
    }
  }

  function bind(){
    sendBtn.addEventListener('click', send);
    clearBtn.addEventListener('click', clearChat);
    inputEl.addEventListener('keydown', (e)=>{
      if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); }
    });
    jurisdictionEl.addEventListener('change', ()=>updateModelStatus());
    addExamples();
  }

  async function init(){
    overlay.classList.remove('hidden');
    overlayText.textContent = 'Initializing Python…';
    await ensureReady();
    overlayText.textContent = 'Ready';
    overlay.classList.add('hidden');
    bind();
    updateModelStatus();
    // Show welcome message
    const welcome = "Hi! I’m here to help with freelancer taxes in plain language. Ask about deductions, quarterly estimated payments, or self-employment tax. I’ll explain the general rule and steps. This isn’t professional tax advice, but I’ll do my best to guide you.";
    window.addMessage('assistant', welcome);
    inputEl.focus();
  }

  if(document.readyState === 'loading'){
    document.addEventListener('DOMContentLoaded', init);
  } else { init(); }
})();
# Freelancer Tax Q&A Helper - Python side
# Public functions (no leading underscore) become tools; we expose only process_user_query.
# Do not import heavy packages; keep prompt compact for local models.

from typing import List, Tuple
import asyncio
from textwrap import shorten
import js

# Tunables (use triple-bracket templating with defaults)
MAX_CONTEXT_CHARS = int('[[[MAX_CHARS|6000]]]')
MAX_MEMORY_MESSAGES = 8  # keep it small for local models

# Conversation memory: list of (user, assistant)
HISTORY: List[Tuple[str, str]] = []

SYSTEM_PROMPT = (
    "You are a plain-language tax-question helper for freelancers and independent contractors. "
    "Answer questions about deductions, quarterly estimated taxes, and self-employment tax. "
    "Instructions:\n"
    "- Give clear, friendly explanations in simple sentences.\n"
    "- Start with a direct answer in 1-2 sentences.\n"
    "- Then include: 'Why:' with the general rule.\n"
    "- If helpful, add 'How to figure it:' with steps or a small example or quick math.\n"
    "- Ask 1-2 clarifying questions if key info is missing.\n"
    "- If the user states a jurisdiction, adapt to it. If not, assume U.S. federal rules and note when rules vary by state/country.\n"
    "- Do not invent precise statutory citations. You may reference high-level sources like 'IRS Publication 334/535' as examples.\n"
    "- If the topic is not tax, politely steer back to freelancer tax topics.\n"
    "- Always end with exactly this one-line reminder: This isn't professional tax advice.\n"
    "Formatting:\n"
    "- Use short paragraphs or bullet points.\n"
    "- Show simple calculations clearly.\n"
)


async def _llm_call(prompt: str, system_prompt: str) -> str:
    """Call the injected provider-neutral LLM from Python via JS.

    Args:
        prompt: The user/context prompt string.
        system_prompt: The system instruction string.
    """
    # window.callLLM(prompt, systemPrompt) is injected by the platform
    try:
        resp = await js.callLLM(prompt, system_prompt)
    except Exception as e:
        return f"Sorry, I couldn't reach the language model service. Please try again. (Error: {e})"
    # resp should be a string
    try:
        return str(resp)
    except Exception:
        return "Sorry, I couldn't parse the model response. Please try again."


def _reset_state():
    """Reset in-memory conversation state (helper; not exposed as a tool)."""
    HISTORY.clear()


def _build_context(jurisdiction: str) -> str:
    """Build textual conversation context limited by character budget.

    Args:
        jurisdiction: The jurisdiction hint selected by the user.
    """
    parts: List[str] = []
    jur = jurisdiction.strip() or "US"
    parts.append(f"Jurisdiction: {jur}")
    parts.append("You are assisting a self-employed freelancer with taxes.")
    # Add recent history
    if HISTORY:
        parts.append("Conversation so far (most recent last):")
        for u, a in HISTORY[-MAX_MEMORY_MESSAGES:]:
            parts.append(f"User: {u}")
            parts.append(f"Assistant: {a}")
    context = "\n".join(parts)
    # Truncate if needed
    if len(context) > MAX_CONTEXT_CHARS:
        context = context[-MAX_CONTEXT_CHARS:]
    return context


async def process_user_query(query: str, jurisdiction: str = "US") -> str:
    """Handle a user query end-to-end: build context, call the LLM, update memory.

    Args:
        query: The user's tax question.
        jurisdiction: Jurisdiction hint (e.g., 'US', 'UK').
    """
    q = (query or "").strip()
    if not q:
        return "Please enter a freelancer tax question to begin. This isn't professional tax advice."

    context = _build_context(jurisdiction)
    # Compose final prompt: context + current turn
    prompt = (
        f"{context}\n\n"
        f"User: {q}\n"
        f"Assistant:"
    )

    answer = await _llm_call(prompt, SYSTEM_PROMPT)

    # Ensure the mandatory reminder is present (add if missing)
    reminder = "This isn't professional tax advice."
    if reminder not in answer:
        if answer.endswith("."):
            answer = f"{answer}\n\n{reminder}"
        else:
            answer = f"{answer}.\n\n{reminder}"

    # Update memory
    try:
        HISTORY.append((q, answer))
        # Trim memory if it grows too large
        if len(HISTORY) > MAX_MEMORY_MESSAGES * 2:
            del HISTORY[: len(HISTORY) - MAX_MEMORY_MESSAGES]
    except Exception:
        pass

    return answer