A workflow automation tool inspired by n8n, built from scratch in TypeScript.
You connect nodes (boxes) with edges (arrows) on a canvas. A trigger starts the workflow, and each node does one job and passes its output to the next one.
⏰ Every 9:00 AM ──► 🌐 Get weather ──► 📨 Send to Telegram
🚧 Work in progress. I'm building this as a learning project. See the roadmap for what's done.
Not affiliated with n8n GmbH.
- 🧩 Visual editor: drag nodes and connect them on a canvas (React Flow)
- ⚡ Triggers: manual run, webhook URL, cron schedule
- 🌐 Nodes: HTTP Request, Set, If, Telegram, Email, and more
- 📜 Execution history: status, per-node output and errors for every run
- 🔐 Credentials: API keys stored encrypted
- 👤 Auth: email/password login (better-auth)
- 📘 API docs: Swagger / OpenAPI page
| Layer | Tech |
|---|---|
| Runtime | Bun |
| Language | TypeScript |
| API | Elysia |
| Database | PostgreSQL (Neon) |
| ORM / migrations | Drizzle ORM + drizzle-kit |
| Validation | ArkType |
| Auth | better-auth |
| Frontend (planned) | React + React Flow |
| Deployment (planned) | Docker Compose on AWS EC2, Caddy, GitHub Actions |
erDiagram
users ||--o{ workflows : owns
users ||--o{ credentials : owns
users ||--o{ sessions : "logged in as"
users ||--o{ accounts : "signs in with"
workflows ||--o{ executions : "runs as"
users {
text id PK
text name
text email UK
boolean email_verified
text image
timestamp created_at
timestamp updated_at
}
workflows {
uuid id PK
text user_id FK
text name
boolean active
jsonb nodes
jsonb edges
timestamp created_at
timestamp updated_at
}
credentials {
uuid id PK
text user_id FK
text name
text type
text data "encrypted"
timestamp created_at
timestamp updated_at
}
executions {
uuid id PK
uuid workflow_id FK
execution_status status
trigger_mode mode
jsonb data "output per node"
text error
timestamp created_at
timestamp finished_at
}
sessions {
text id PK
text user_id FK
text token UK
timestamp expires_at
text ip_address
text user_agent
}
accounts {
text id PK
text user_id FK
text account_id
text provider_id
text password "hashed"
}
verifications {
text id PK
text identifier
text value
timestamp expires_at
}
| Table | What it stores |
|---|---|
users |
people who log in (shape compatible with better-auth) |
workflows |
one automation: name, on/off switch, and the canvas (nodes + edges as JSON) |
credentials |
saved API keys for nodes, encrypted |
executions |
one row per run: what started it, its status, each node's output, and any error |
sessions |
one row per logged-in browser/device; its token is the login cookie (better-auth) |
accounts |
how a user signs in; for email/password this holds the hashed password (better-auth) |
verifications |
short-lived codes such as email verification or password reset (better-auth) |
Enums
execution_status:pending→running→completed/failedtrigger_mode:manual|webhook|cron
Deleting a user cascades to their workflows, credentials, sessions and accounts, and deleting a workflow cascades to its executions.
{
"nodes": [
{ "id": "1", "type": "manualTrigger", "name": "Start",
"position": { "x": 100, "y": 200 }, "params": {} },
{ "id": "2", "type": "httpRequest", "name": "Get weather",
"position": { "x": 350, "y": 200 },
"params": { "url": "https://api.example.com/weather", "method": "GET" } }
],
"edges": [
{ "id": "e1", "source": "1", "target": "2" }
]
}The shapes of nodes and edges are defined once with ArkType in src/types/workflow.ts. That one definition provides both runtime validation and the TypeScript types.
n8n/
├── src/
│ ├── db/
│ │ ├── index.ts # Drizzle connection (Bun SQL → Postgres)
│ │ └── schema/ # one file per table
│ │ ├── user.ts
│ │ ├── workflow.ts
│ │ ├── credential.ts
│ │ ├── execution.ts
│ │ ├── session.ts # better-auth tables
│ │ ├── account.ts
│ │ ├── verification.ts
│ │ └── index.ts # re-exports all tables
│ ├── types/
│ │ └── workflow.ts # ArkType: WorkflowNode, WorkflowEdge
│ ├── nodes/
│ │ ├── types.ts # NodeContext + NodeDefinition (the shape every node follows)
│ │ ├── manualTrigger.ts # starts a workflow by hand (/run) or from a webhook
│ │ ├── scheduleTrigger.ts # starts a workflow on a cron schedule
│ │ ├── httpRequest.ts # calls a URL and returns the JSON response
│ │ ├── set.ts # adds or replaces one field in the data
│ │ └── index.ts # node registry: type name → node code
│ ├── engine/
│ │ ├── runWorkFlow.ts # runs a workflow: trigger → follow edges → pass data along
│ │ ├── startExecution.ts # creates an execution row, runs the workflow, saves completed/failed
│ │ └── cronManager.ts # starts/stops cron jobs for active workflows
│ ├── routes/
│ │ ├── workflows.ts # workflow CRUD, run, webhook, executions per workflow
│ │ └── executions.ts # latest executions, one execution by id
│ ├── auth/
│ │ ├── index.ts # better-auth config: Drizzle adapter, email/password
│ │ └── plugin.ts # Elysia plugin: mounts /api/auth/*, adds the `auth: true` route option
│ ├── lib/
│ │ ├── ApiError.ts # consistent JSON error responses
│ │ └── ApiResponse.ts
│ ├── scripts/
│ │ └── sampleWorkflow.ts # runs a sample workflow through the engine
│ └── index.ts # Elysia server: mounts routes, Swagger, loads cron schedules
├── drizzle/ # generated SQL migrations
└── drizzle.config.ts
Planned: src/web/ (the React Flow editor).
git clone https://github.com/samchinmaya/n8n.git
cd n8n
bun installCreate a .env file in the project root:
DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require
PORT=3000
BETTER_AUTH_SECRET= # a long random string: openssl rand -base64 32
BETTER_AUTH_URL=http://localhost:3000Bun loads .env automatically. Never commit .env.
bun run db:migratebun run db:studioThen open https://local.drizzle.studio. Safari and Brave block it by default, so use Chrome/Firefox or turn off Brave Shields for that site.
| Command | What it does |
|---|---|
bun run db:generate |
create a SQL migration from schema changes |
bun run db:migrate |
apply migrations to the database |
bun run db:studio |
open Drizzle Studio to browse tables |
bun run dev |
start the API server with auto-reload (http://localhost:3000) |
bun run src/scripts/sampleWorkflow.ts |
run the sample workflow through the engine and print each node's output |
Interactive docs (Swagger): http://localhost:3000/openapi
| Method | Route | What it does |
|---|---|---|
GET |
/health |
health check |
POST |
/api/auth/sign-up/email |
create an account (name, email, password) |
POST |
/api/auth/sign-in/email |
log in; sets the session cookie |
GET |
/api/auth/get-session |
who is logged in (from the cookie) |
POST |
/api/auth/sign-out |
log out |
GET |
/workflows |
list workflows |
POST |
/workflows |
create a workflow |
GET |
/workflows/:id |
get one workflow |
PATCH |
/workflows/:id |
update a workflow (setting active starts/stops its cron schedule) |
DELETE |
/workflows/:id |
delete a workflow |
POST |
/workflows/:id/run |
run a workflow now and return the execution |
POST |
/workflows/:id/webhook |
run an active workflow; the request body becomes the trigger's input |
GET |
/workflows/:id/executions |
a workflow's runs, newest first |
GET |
/executions |
the latest 20 runs |
GET |
/executions/:id |
one run: status, each node's output, error |
🚧 The workflow and execution routes still use a fixed test user. Protecting them with the logged-in user (
auth: true) is the next step of Day 5.
Every run is saved in executions with its trigger mode (manual, webhook, cron), status, per-node output and error.
Start a workflow with a scheduleTrigger node whose params.cron is a cron expression, then set the workflow to active: true:
{ "id": "1", "type": "scheduleTrigger", "name": "Every minute", "position": { "x": 0, "y": 0 }, "params": { "cron": "*/1 * * * *" } }Active schedules are reloaded when the server starts.
- Day 1: Database. Schema, ArkType types, first migration
- Day 2: Nodes + engine. Node interface, first nodes (Manual Trigger, HTTP Request, Set), node registry, the workflow runner
- Day 3: API. Elysia routes for workflows and executions, Swagger docs, consistent API responses and errors
- Day 4: Triggers. Webhook and cron triggers
- Day 5: Auth + credentials. better-auth, encrypted API keys, per-user data
- better-auth tables, email/password sign-up and login, sessions, Elysia auth plugin
- protect workflow and execution routes per user
- encrypted credentials
- Day 6: Frontend part 1. React Flow editor, workflow list, save and load
- Day 7: Frontend part 2. Node settings, run button, executions page, If node
- Day 8: Deploy. Docker, AWS EC2, Caddy (HTTPS), GitHub Actions CI/CD
- n8n: the original open-source workflow automation tool