← Back to list

Hackathon Co-Pilot

I built an AI agent that builds hackathon projects — using Kestra, Obsidian .

Pawan kumar · 2026-05-10 08:22 · 3 claps · 6.5 min read
#kestra #automation #orchestration #ai #ai-agent
Open on Medium ↗
Wiki topics: AGT · AI Agents AI · AI · General ⏱️ · Productivity

Hackathon Co-Pilot

I built an AI agent that builds hackathon projects — using Kestra, Obsidian .

Day 1 of the WeMakeDevs × Kestra Orchestration Challenge: a Planner LLM that decomposes a one-line brief into a real GitHub repo, a Slack announcement, and a live agent graph in Obsidian.

The brief that started this

The WeMakeDevs × Kestra Orchestration Challenge invites developers to deliver something cool on Kestra within a week and share their work. I had completed the certification process and posted a welcome note. The following content was meant to go viral and create the social buzz.

I had four scenarios on a sticky note:

  1. Hackathon Co-Pilot— paste a brief, get a starter repo. Meta. Self-referential.

  2. GitHub Triage Bot — auto-label issues + nudge stale PRs.

  3. Slack-to-Ship— turn a Slack thread into a deploy.

  4. Market Research Analyst — fan out across web sources, summarize.

I went with Co-Pilot because it was shareable. It’s hackathon project for helping people start hackathon projects — the sort of thing people are always reposting. The product (real GitHub repo) can be clicked on by viewers. And the WeMakeDevs audience is hackathon native; it hits the mark right out of the box. The restriction I placed on myself while still designing the software: if it wasn’t visible in the 30-second screen recording, cut it. This one rule prevented scope creep before it could even begin. No website frontend. No Vercel automatic deployment. No self-healing pipelines. No multi-tenant authentication. No thread of tweets explaining it all. Simply: cascade visible, graph visible, and repo visible.

Fig(a) shows the Slack notification and Repo creation on user account

Fig(a) shows the Slack notification and Repo creation on user account

What I actually built


hackathon.copilot.planner   ←  AIAgent (Gemini 2.5 Flash + 4× KestraFlow tool)
    ├─ hackathon.copilot.researcher    →  picks LANGUAGE / FRAMEWORK / DEPS
    ├─ hackathon.copilot.coder         →  writes a self-contained source file
    ├─ hackathon.copilot.reviewer      →  Python script — runs the code in a sandbox, captures pass/fail
    └─ hackathon.copilot.communicator  →  Python script — creates GitHub repo, posts to Slack, writes a final report

Five Kestra flows, all in one namespace. The Planner is the only one that uses tool-calling — the other four are leaf agents (Researcher, Coder) or deterministic glue (Reviewer, Communicator).

The main plug-in for Kestra here is io.kestra.plugin.ai.tool.KestraFlow. We apply it to our AIAgent so that now our LLM can use flows as its tools. The moment our LLM triggers call_coder(task_description=”…”), Kestra will execute hackathon.copilot.coder and return the results as part of our LLM context. For the end-user, it might look like a function call made by the agent, but for Kestra, it is just an execution of a flow, nothing special about it.

Here’s the planner’s tool spec, abbreviated:

```yaml
- id: plan_and_route
type: io.kestra.plugin.ai.agent.AIAgent
allowFailure: true
provider:
type: io.kestra.plugin.ai.provider.GoogleGemini
apiKey: "{{ kv('GEMINI_API_KEY') }}"
modelName: gemini-2.5-flash
maxSequentialToolsInvocations: 6
systemMessage: |
You drive a team of FOUR specialist agents that run as Kestra
subflow executions. Each tool call shows up as a nested execution
in the Kestra UI - that visible cascade IS the product.
ABSOLUTE RULE - STRICTLY SEQUENTIAL: Call EXACTLY ONE tool per turn.
WAIT for that tool's RESULT. READ the result. THEN decide the next.
NEVER call multiple tools in the same turn.
Order:
Turn 1: call_researcher(brief=<the user's brief verbatim>)
Turn 2: call_coder(task_description=<rephrase, mention the LANGUAGE>)
Turn 3: call_reviewer(language, source_code, filename)
Turn 4: call_communicator(project_name, brief, stack_summary,
source_filename, source_code, review_summary)
Turn 5: Reply with a 2–3 sentence summary including the repo URL.
prompt: "{{ inputs.goal }}"
tools:
- type: io.kestra.plugin.ai.tool.KestraFlow
namespace: hackathon.copilot
flowId: researcher
kestraUrl: http://localhost:8080
auth:
username: "{{ kv('KESTRA_USERNAME') }}"
password: "{{ kv('KESTRA_PASSWORD') }}"
inheritLabels: true
labels:
copilot_goal_id: "{{ inputs.goal_id }}"
copilot_parent_id: "{{ execution.id }}"
# … three more KestraFlow tools, one per specialist

![](https://miro.medium.com/v2/resize:fit:649/1*7jBZD53sgcIcjOa6DXPgGg.png)

![](https://miro.medium.com/v2/resize:fit:649/1*BfVWtux6Bm5OXy5Z6u9qPQ.png)

A few things worth noting about that snippet, because each one cost me time:
1. **kestraUrl**: [http://localhost:8080](http://localhost:8080`) — when the KestraFlow tool fires a subflow, it calls Kestra’s own REST API to do it. Inside the container, Kestra binds `:8080`; the host port mapping is `:18080`. If you don’t override `kestraUrl`, Kestra defaults to whatever `kestra.url` is configured to and the call hits a non-existent port. You get “connection refused” with no obvious culprit.
2. **auth**: { username, password } — Kestra OSS 1.3 makes basic-auth mandatory. The KestraFlow plugin requires authentication to hit its own REST endpoints. If you omit this configuration element, you’ll get an unexpected 401 response — the AIAgent will fail without any error message.
3. **AllowFailure**: true on the planner — If the LLM’s *last* summary call hits rate limits, the AIAgent ends with a WARNING status rather than FAILURE status. The chain still ends successfully, the sync still executes, the demo still succeeds.
4. **maxSequentialToolsInvocations**: 6 — small cap. The Planner makes ~5 tool decisions max. Setting this prevents runaway loops if the LLM gets confused.
5. **inheritLabels**: true plus per-tool `labels` — this is how I tie all child executions to the same goal. Every subflow execution carries `copilot_goal_id` and `copilot_parent_id` labels, set by the parent. The Obsidian sync script reads these labels to draw the right wiki-links.

Each specialist flow ends with a Python script that calls `obsidian_sync.py`:
- id: sync_to_obsidian
type: io.kestra.plugin.scripts.python.Script
taskRunner:
type: io.kestra.plugin.core.runner.Process
env:
AGENT: researcher
EXEC_ID: "{{ execution.id }}"
GOAL_ID: "{{ labels.copilot_goal_id ?? trigger.executionId ?? execution.id }}"
PARENT_ID: "{{ labels.copilot_parent_id ?? trigger.executionId ?? '' }}"
TASK_INPUT: "{{ inputs.brief }}"
AGENT_OUTPUT: "{{ outputs.pick_stack.textOutput ?? '' }}"
script: |
import os, subprocess, sys
def cap(s, n): return (s or "")[:n]
subprocess.run([
sys.executable, "/app/scripts/obsidian_sync.py",
" - agent", os.environ["AGENT"],
" - execution-id", os.environ["EXEC_ID"],
" - goal-id", os.environ["GOAL_ID"],
" - parent-id", os.environ["PARENT_ID"],
" - input", cap(os.environ.get("TASK_INPUT"), 300),
" - output", cap(os.environ.get("AGENT_OUTPUT"), 800),
" - tools-used", "AIAgent",
" - children", "",
" - status", "completed",
], check=False)

And there is a reason for that, and why not in Pebble. **Pebble (the templating language used by Kestra) doesn’t have the `truncate` filter.** The approach with `slice(0, 300)` fails because it fails to parse when less than 300 characters are passed. The ideal fix would be passing the whole thing through an `env:` block and doing the truncation on Python, where the real language is available. Plus, no escaping problems with random user inputs. Backticks, quotes, anything goes in the prompt.

# **The Obsidian graph trick**

This is the part most of the build time went into, and it’s the part the demo screen-recording sells.

Every flow’s final task writes one markdown note into a folder structure inside the user’s Obsidian vault:
$VAULT/kestra-copilot/
├── goals/
│ └── goal_<execution_id>.md
├── executions/
│ └── 2026–05–09T10–22–15Z_researcher_2fgmeeae.md
│ └── 2026–05–09T10–22–29Z_reviewer_yudxcled.md
│ └── …
├── tools/
│ └── tool_kestraflow.md
│ └── tool_aiagent.md
│ └── …
└── reports/
└── report_<goal_id>.md ← written only by Communicator at the end

Each note has YAML frontmatter (so Obsidian’s Dataview/graph features can filter on `type: agent_execution` etc.) and a body full of Obsidian wiki-links (`[[goal_xyz]]`, `[[2026–05–09T10–22–15Z_researcher_2fgmeeae]]`).

The wiki-links are everything. Obsidian’s graph view doesn’t render edges from frontmatter — it renders edges from `[[wiki-links]]` in the body of the note. So my notes look like:
 - -
type: agent_execution
agent: coder
execution_id: 7UJLdCOCQf1RqIbjFGJyOh
goal_id: 6ZVvUI6iwed6xRt37PVZDY
parent_id: 6ZVvUI6iwed6xRt37PVZDY
status: completed
 - -
# coder - 2026–05–09T10–07–41Z
**Goal:** [[goal_6zvvui6iwed6xrt37pvzdy]]
**Parent:** [[6ZVvUI6iwed6xRt37PVZDY]]
**Status:** `completed`
## Output
LANGUAGE: Python
FRAMEWORK: FastAPI
SOURCE:
```python
from fastapi import FastAPI, WebSocket
import asyncpg, asyncio
…


# **What’s next**

A few directions, in priority order:

1. **The 30-second demo video.**That’s literally the only “feature” I haven’t shipped yet — the recording itself. Coming this week.

2. **A Reviewer that actually ****fixes**** code.** Right now it runs the code and reports pass/fail. The next step is to feed failure output back to the Coder for a retry loop. That’s a 4-line change to the planner’s system prompt.

3. **Web search in the Researcher.** Kestra has `io.kestra.plugin.ai.tool.TavilyWebSearch` built in. Plugging it in would let the Researcher pull **current** dependency versions instead of relying on training-data cutoffs. One YAML change, one new API key.

4. **Replace the LLM-driven cascade with a hybrid model.** Right now the Planner LLM has to drive 4 steps in sequence — when it rate-limits halfway, things get weird. A cleaner design: the LLM picks the **first** step and **strategy**, and a deterministic Subflow chain fans out from there. That keeps the “LLM in the loop” claim real but makes the cascade rock-solid.

If any of that is interesting, the code is open. Drop a brief into an issue and I’ll point the Planner at it.

## **Credits**

Built for the [WeMakeDevs × Kestra Orchestration Challenge](https://kestra.io). Thanks to the [Kestra](https://kestra.io) team for an orchestrator that ships an `AIAgent` task with a `KestraFlow` tool out of the box — the whole “LLM dispatching subflows” thing is barely 10 lines of YAML because of that. And to [WeMakeDevs](https://wemakedevs.org) for putting up the challenge. The hashtag for the contest is `#KestraAcademy`.

The generated demo project: [Pavankumar07s/realtime-whiteboard](https://github.com/Pavankumar07s/realtime-whiteboard)

The 30s clip: coming this week.

메타데이터
post_id
964c9b8a0e08
slug
hackathon-co-pilot-964c9b8a0e08
url
https://medium.com/@pawankumar14662693/hackathon-co-pilot-964c9b8a0e08
canonical_url
https://medium.com/@pawankumar14662693/hackathon-co-pilot-964c9b8a0e08
author_url
https://medium.com/@pawankumar14662693
status
ok
fetched_at
2026-06-09 14:34:10