How I Built Form Infrastructure for AI Agents

# ai# python
How I Built Form Infrastructure for AI AgentsVincent

AI agents are getting better at reasoning but they still struggle with one thing: structured input....

AI agents are getting better at reasoning but they still struggle with one thing: structured input. They can generate text, analyze data, and make decisions — but when you need them to collect information from users in a consistent format, you hit a wall.

I built AgentForms to solve exactly that. It's form infrastructure designed from the ground up for AI agents, not humans. Here's how it works.

The Problem

Traditional form builders (Typeform, Jotform, Google Forms) are human-first. They optimize for UX, drag-and-drop editors, and pretty templates. But AI agents don't care about aesthetics — they need reliable APIs, structured data, and programmatic control.

Every time I tried to build an AI application that needed user input, I ended up writing the same boilerplate: form validation, webhook handling, email notifications, payment processing. After the third time, I decided to build it once properly.

Architecture

AgentForms runs as two Docker containers:

services:
  relay:
    build: ./relay
    ports:
      - "5060:5060"
    volumes:
      - ./app:/app/app
      - ./data:/app/data
    environment:
      - DATABASE_URL=sqlite:///app/data/agentforms.db

  worker:
    build: ./worker
    depends_on:
      - relay
      - redis
    environment:
      - REDIS_URL=redis://redis:6379
Enter fullscreen mode Exit fullscreen mode

The relay handles HTTP requests. The worker processes async tasks. Redis sits between them for job queuing. Simple, testable, deployable anywhere.

Form Submission Flow

@app.route('/forms/<form_id>/submit', methods=['POST'])
def submit_form(form_id):
    form = Form.query.get_or_404(form_id)

    data = request.get_json()
    errors = validate(data, form.schema)
    if errors:
        return jsonify({'errors': errors}), 422

    submission = Submission(form_id=form_id, data=data)
    db.session.add(submission)
    db.session.commit()

    worker.enqueue('process_submission', submission_id=submission.id)

    return jsonify({'id': submission.id}), 201
Enter fullscreen mode Exit fullscreen mode

Key decisions:

  • SQLite with WAL mode — single-server deployment, zero configuration. WAL mode handles concurrent reads without locking.
  • Schema validation at submission time — fail fast, return structured errors.
  • Worker queue for side effects — email, payments, webhooks all happen async. The API response is immediate.

OAuth Integration

One of the harder pieces was OAuth. Agents need to authenticate users, but OAuth flows are notoriously complex to implement correctly.

Here's our OAuth callback handler:

@app.route('/auth/oauth/callback', methods=['GET'])
def oauth_callback():
    code = request.args.get('code')
    provider = request.args.get('provider')

    token = oauth_providers[provider].exchange_code(code)
    user_info = oauth_providers[provider].get_user_info(token)

    user = User.query.filter_by(
        provider=provider,
        provider_id=user_info['id']
    ).first()

    if not user:
        user = User(
            provider=provider,
            provider_id=user_info['id'],
            email=user_info['email']
        )
        db.session.add(user)

    session_token = generate_session_token(user.id)
    db.session.commit()

    return redirect(f"/dashboard?token={session_token}")
Enter fullscreen mode Exit fullscreen mode

We abstract each provider behind a common interface. Adding a new provider means implementing three methods: exchange_code(), get_user_info(), and refresh_token(). No changes to the core auth flow.

Stripe Billing

Payment processing is where most indie SaaS products fail. Not because the integration is hard — because handling edge cases is hard. Failed payments, webhook retries, subscription changes, refunds.

Our Stripe webhook handler:

@app.route('/billing/stripe/webhook', methods=['POST'])
def stripe_webhook():
    payload = request.get_data()
    sig_header = request.headers.get('Stripe-Signature')

    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, stripe_webhook_secret
        )
    except stripe.error.SignatureVerificationError:
        return 'Invalid signature', 400

    if event['type'] == 'checkout.session.completed':
        session = event['data']['object']
        plan_id = session['metadata'].get('plan_id')
        customer_email = session['customer_email']

        activate_subscription(customer_email, plan_id)

    elif event['type'] == 'invoice.payment_failed':
        customer = event['data']['object']['customer']
        handle_failed_payment(customer)

    return 'OK', 200
Enter fullscreen mode Exit fullscreen mode

The critical detail: we verify the Stripe signature before processing anything. This prevents spoofed webhook attacks — a common mistake in indie SaaS implementations.

Webhook Forwarding

Users want their form submissions forwarded to their own endpoints — Slack, Discord, custom APIs.

Our webhook relay handles retries with exponential backoff:

def forward_webhook(submission_id, target_url, max_retries=3):
    submission = Submission.query.get(submission_id)
    payload = submission.data

    for attempt in range(max_retries):
        try:
            response = requests.post(
                target_url,
                json=payload,
                timeout=10,
                headers={'Content-Type': 'application/json'}
            )

            if response.status_code < 500:
                log_webhook_delivery(submission_id, response.status_code)
                return True

        except requests.exceptions.RequestException as e:
            delay = 2 ** attempt
            time.sleep(delay)

    log_webhook_failure(submission_id, target_url)
    return False
Enter fullscreen mode Exit fullscreen mode

Simple, but it handles the 80% case. Server errors (5xx) get retried. Client errors (4xx) don't — your endpoint told us something's wrong with the data, so retrying won't help.

Why This Matters for AI

AI agents need structured input to be useful. A form is a contract: "give me these fields, I'll give you this result." Without that structure, agents are guessing.

AgentForms provides that structure as an API. Agents can:

  • Create forms programmatically
  • Submit data with validation
  • Trigger workflows on submission
  • Handle payments and billing

All through a consistent API. No scraping HTML, no guessing at field names, no dealing with CAPTCHAs.

What's Next

The core is built. 343 tests, Docker deployment, Stripe billing, OAuth, webhook forwarding. Now it needs users.

If you're building AI applications that need structured input, AgentForms is probably worth looking at. Starter plan is $9/month — cheaper than debugging your own webhook retry logic.


Building in public. Questions or contributions? Hit me up on Nostr or check out the docs at agentforms.io.