Vincent 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.
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.
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
The relay handles HTTP requests. The worker processes async tasks. Redis sits between them for job queuing. Simple, testable, deployable anywhere.
@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
Key decisions:
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}")
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.
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
The critical detail: we verify the Stripe signature before processing anything. This prevents spoofed webhook attacks — a common mistake in indie SaaS implementations.
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
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.
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:
All through a consistent API. No scraping HTML, no guessing at field names, no dealing with CAPTCHAs.
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.