Tulr.io was an ambitious side project: one canvas where non-technical users could combine tables, forms, calendars, and video — then wire them together with automation to build real apps (applicant trackers, email workflows, video pitches) without code.
The hardest part wasn't the features
It was the data model. When everything can connect to everything, you need a schema flexible enough to represent any "block" but strict enough to validate.
type BlockType = "table" | "form" | "calendar" | "video";
interface Block<T = unknown> {
id: string;
type: BlockType;
data: T;
connections: string[]; // ids of blocks this one feeds into
}
const isTable = (block: Block): block is Block<TableData> =>
block.type === "table";Automations as a graph
Each automation was a small directed graph: a trigger block, some transforms, and an action. Modelling it explicitly made it debuggable.
type Node = { id: string; run: (ctx: Context) => Promise<Context> };
async function execute(nodes: Node[], ctx: Context) {
for (const node of nodes) {
ctx = await node.run(ctx); // each step transforms the context
}
return ctx;
}What I took away
- Constrain the canvas. Infinite flexibility paralyses users; opinionated defaults set them free.
- Make the invisible visible. Showing automations as a graph turned "magic" into something people could reason about.
- Ship the smallest useful loop first. I over-built early; the wins came from one tight create → connect → automate loop.