Overview
Autonix is a workflow automation platform in the style of n8n and Zapier. Users build workflows by dragging and connecting nodes on a visual canvas, then execute them with a single click. The platform supports manual triggers, HTTP request nodes, Google Form triggers, Stripe triggers, and AI provider nodes for OpenAI, Anthropic, and Google Gemini.
The stack is Next.js 15, TypeScript, Prisma with PostgreSQL, tRPC for type-safe APIs, Inngest for async workflow execution, React Flow (@xyflow/react) for the visual editor, Jotai for editor state, Better Auth for authentication, and Polar.sh for payments and subscriptions.
Try Autonix or explore the source code.
Architecture
Visual Node Editor
The workflow editor is built on @xyflow/react (React Flow). Each node type in the system maps to a custom React component registered via the nodeTypes prop. The editor uses Jotai to share the ReactFlowInstance across components — the save button, for example, reads the current graph state from the atom:
const [nodes, setNodes] = useState<Node[]>(workflow.nodes);
const [edges, setEdges] = useState<Edge[]>(workflow.edges);
const onNodesChange = useCallback(
(changes: NodeChange[]) =>
setNodes((nodesSnapshot) => applyNodeChanges(changes, nodesSnapshot)),
[setNodes],
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={NODE_COMPONENTS}
fitView
snapGrid={[10, 10]}
snapToGrid
panOnScroll
>
<Background />
<Controls />
<MiniMap />
<Panel position="top-left">
<AddNodeButton />
</Panel>
</ReactFlow>
);
When a user saves, the editor atom's getNodes() and getEdges() are called to capture the full graph state and persist it via a tRPC mutation.
Topological Sort Engine
Before executing a workflow, the node graph must be linearized into a safe execution order. The system uses the toposort library to perform a topological sort on the workflow's connection edges, with cycle detection built in:
export const topologicalSort = ({
nodes,
connections,
}: {
nodes: Node[];
connections: Connection[];
}) => {
const edges: Array<[string, string]> = connections.map(
({ fromNodeId, toNodeId }) => [fromNodeId, toNodeId],
);
// Include disconnected nodes
nodes.forEach((n) => {
if (!connectedNodeIds.has(n.id)) {
edges.push([n.id, n.id]);
}
});
try {
sortedNodeIds = toposort(edges);
sortedNodeIds = [...new Set(sortedNodeIds)];
} catch (err) {
if (err instanceof Error && err.message.toLowerCase().includes("cyclic")) {
throw new Error("Workflow contains a cycle");
}
throw err;
}
const nodeMap = new Map<string, Node>(nodes.map((n) => [n.id, n]));
return sortedNodeIds.map((nodeId) => nodeMap.get(nodeId)!).filter(Boolean);
};
Executor Registry Pattern
Each node type maps to an executor function through a type-safe registry. This keeps the dispatch logic centralized and ensures every NodeType enum value has a corresponding implementation:
export const executorRegistry: Record<NodeType, NodeExecutor> = {
[NodeType.MANUAL_TRIGGER]: manualTriggerExecutor,
[NodeType.HTTP_REQUEST]: httpRequestExecutor,
[NodeType.GOOGLE_FORM_TRIGGER]: GoogleFormTriggerNodeExecutor,
[NodeType.STRIPE_TRIGGER]: stripeTriggerExecutor,
...AI_PROVIDER_EXECUTORS,
};
export const getExcecutor = (type: NodeType): NodeExecutor => {
const executor = executorRegistry[type];
if (!executor) {
throw new Error(`No executor found for node type: ${type}`);
}
return executor;
};
Inngest Async Execution
Workflows execute asynchronously through Inngest. The main function creates an execution record, topologically sorts the nodes, then iterates through them in order — each node's executor receives the accumulated context and returns an updated context for the next node:
export const executeWorkflow = inngest.createFunction(
{ id: INNGEST_EVENTS.EXECUTE_WORKFLOW.ID, retries },
{ event: INNGEST_EVENTS.EXECUTE_WORKFLOW.NAME, channels: [...] },
async ({ event, step, publish }) => {
const executionId = await step.run("create-execution-record", async () => {
// Create execution record in DB
});
let context = event.data.initialData || {};
const { workflow, sortedNodes } = await step.run(
"prepare-workflow",
async () => {
const workflow = await prisma.workflow.findUniqueOrThrow({
where: { id: workflowId },
include: { nodes: true, connections: true },
});
return {
workflow,
sortedNodes: topologicalSort({
nodes: workflow.nodes,
connections: workflow.connections,
}),
};
}
);
for (const node of sortedNodes) {
const executor = getExcecutor(node.type as NodeType);
context = await executor({
data: node.data,
nodeId: node.id,
context,
step,
publish,
userId: workflow.userId,
});
}
}
);
AI Provider Executors
The AI provider executors support OpenAI, Anthropic, and Gemini through the Vercel AI SDK's generateText function. A key feature is Handlebars template interpolation — node prompts can reference outputs from upstream nodes using template variables. The executor compiles the template with the accumulated workflow context before sending it to the AI provider:
export const getAIProviderExecutor: (provider: AIProvider) => NodeExecutor =
(provider) =>
async ({
context,
step,
data: { variableName, prompt },
nodeId,
publish,
userId,
}) => {
const result = await step.run("ai-provider-execution", async () => {
const interpolatedPrompt = Handlebars.compile(prompt)(context);
const userSettings = new UserSettings(user?.settings);
const apiKey = userSettings.getVal(provider);
const decryptedKey = decrypt(apiKey);
const model = getAIProviderModel({ provider, apiKey: decryptedKey });
const { text } = await generateText({
model,
prompt: interpolatedPrompt,
});
return {
...context,
[variableName]: {
[provider.toLowerCase()]: {
modelResponse: { prompt: interpolatedPrompt, text },
},
},
};
});
return result;
};
Inngest Realtime Channels
Each node type has an associated realtime channel that publishes status updates (loading, success, error) as execution progresses. The client subscribes to these channels to show live progress indicators on the visual editor:
const createAIProviderChannel = (provider: AIProvider) => {
const channelName = getAIProviderChannelName(provider);
return channel(channelName).addTopic(
topic("status").type<{
nodeId: string;
status: "loading" | "success" | "error";
}>(),
);
};
export const geminiChannel = createAIProviderChannel(AI_PROVIDERS.GEMINI);
export const anthropicChannel = createAIProviderChannel(AI_PROVIDERS.ANTHROPIC);
export const openAIChannel = createAIProviderChannel(AI_PROVIDERS.OPENAI);
Encrypted BYOT (Bring Your Own Token)
Users provide their own API keys for AI providers through a settings page. Keys are encrypted before storage and decrypted at execution time. This gives users full control over their usage and costs since Autonix executes actions on their behalf with their own credentials.
Difficult Parts
Topological Sort and Cycle Detection
Converting a visual drag-and-drop graph into an execution-safe order is the core challenge. The graph can have disconnected nodes, multiple paths, and potentially cycles (which would cause infinite execution). The topological sort handles disconnected nodes by adding self-referencing edges, and catches cycles by wrapping the toposort call in error handling that surfaces a clear message to the user. Getting the edge extraction right — mapping React Flow's edge format to toposort's expected [from, to] tuples — required careful translation between the two data models.
Handlebars Template Interpolation
The context-passing system between nodes was one of the trickier parts. Each executor returns an updated context object, and downstream nodes can reference any upstream output using Handlebars syntax like {{httpResponse.data.name}}. This required registering a custom json helper for Handlebars to safely stringify complex objects:
Handlebars.registerHelper("json", (context) => {
const stringifiedData = JSON.stringify(context, null, 2);
return new Handlebars.SafeString(stringifiedData);
});
The challenge is that context shapes are dynamic — they depend on which nodes ran upstream and what they returned. Template errors at runtime (missing variables, wrong paths) needed clear error messages since debugging workflow execution is already harder than debugging synchronous code.
Real-Time Status Coordination
Coordinating Inngest's server-side async execution with client-side UI updates required the realtime channels/topics pattern. Each executor publishes status changes, and the React client subscribes to the relevant channels. The tricky part is handling error states — when an executor fails, it must publish an error status before re-throwing the exception, which means every executor has a try/catch that publishes to the channel in the catch block before propagating the error to Inngest's retry logic.
Try Autonix or explore the source code.