├── packages ├── types │ ├── src │ │ ├── index.js │ │ ├── index.d.ts │ │ └── index.ts │ ├── tsconfig.json │ └── package.json └── workflow-engine │ ├── tsconfig.json │ ├── package.json │ └── src │ ├── index.js │ └── index.ts ├── apps ├── web │ ├── public │ │ └── design-system │ ├── tsconfig.node.json │ ├── vite.config.ts │ ├── src │ │ ├── main.ts │ │ ├── services │ │ │ └── api.ts │ │ ├── data │ │ │ └── help-content.ts │ │ ├── components │ │ │ └── help-modal.ts │ │ ├── workflow-editor.css │ │ └── app │ │ │ └── workflow-editor.ts │ ├── package.json │ ├── tsconfig.json │ └── index.html └── server │ ├── src │ ├── logger.ts │ ├── services │ │ ├── persistence.ts │ │ └── openai-llm.ts │ ├── store │ │ └── active-workflows.ts │ ├── config.ts │ ├── index.ts │ └── routes │ │ └── workflows.ts │ ├── tsconfig.json │ └── package.json ├── .gitmodules ├── .gitignore ├── tsconfig.base.json ├── eslint.config.mjs ├── .github └── workflows │ └── pr.yml ├── package.json ├── LICENSE ├── README.md └── AGENTS.md /packages/types/src/index.js: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /apps/web/public/design-system: -------------------------------------------------------------------------------- 1 | ../../../design-system -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "design-system"] 2 | path = design-system 3 | url = https://github.com/CodeSignal/learn_bespoke-design-system 4 | -------------------------------------------------------------------------------- /apps/server/src/logger.ts: -------------------------------------------------------------------------------- 1 | 2 | export const logger = { 3 | info: (...args: unknown[]) => console.log('[INFO]', ...args), 4 | warn: (...args: unknown[]) => console.warn('[WARN]', ...args), 5 | error: (...args: unknown[]) => console.error('[ERROR]', ...args) 6 | }; 7 | 8 | -------------------------------------------------------------------------------- /packages/types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "rootDir": "src", 6 | "declaration": true, 7 | "composite": true, 8 | "declarationMap": true 9 | }, 10 | "include": [ 11 | "src" 12 | ] 13 | } 14 | 15 | -------------------------------------------------------------------------------- /apps/web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": [ 10 | "vite.config.ts" 11 | ] 12 | } 13 | 14 | -------------------------------------------------------------------------------- /apps/web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | export default defineConfig({ 4 | root: '.', 5 | server: { 6 | port: 5173, 7 | proxy: { 8 | '/api': 'http://localhost:3000' 9 | } 10 | }, 11 | build: { 12 | outDir: 'dist', 13 | emptyOutDir: true 14 | } 15 | }); 16 | 17 | -------------------------------------------------------------------------------- /packages/workflow-engine/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "rootDir": "src", 6 | "declaration": true, 7 | "composite": true, 8 | "declarationMap": true 9 | }, 10 | "include": [ 11 | "src" 12 | ], 13 | "references": [ 14 | { "path": "../types" } 15 | ] 16 | } 17 | 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Build outputs 5 | dist 6 | apps/*/dist 7 | packages/*/dist 8 | 9 | # TypeScript 10 | *.tsbuildinfo 11 | 12 | # Runtime artifacts 13 | data/runs/ 14 | 15 | # Logs 16 | *.log 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | 21 | # Env files 22 | .env 23 | .env.local 24 | .env.*.local 25 | 26 | # OS 27 | .DS_Store 28 | 29 | # Scripts 30 | setup.sh 31 | -------------------------------------------------------------------------------- /apps/server/src/services/persistence.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs/promises'; 2 | import path from 'node:path'; 3 | import type { WorkflowRunRecord } from '@agentic/types'; 4 | 5 | export async function saveRunRecord(runsDir: string, record: WorkflowRunRecord): Promise { 6 | const filePath = path.join(runsDir, `run_${record.runId}.json`); 7 | await fs.writeFile(filePath, JSON.stringify(record, null, 2), 'utf-8'); 8 | } 9 | 10 | -------------------------------------------------------------------------------- /apps/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "outDir": "dist", 6 | "rootDir": "src", 7 | "declaration": true, 8 | "declarationMap": true 9 | }, 10 | "include": [ 11 | "src" 12 | ], 13 | "references": [ 14 | { "path": "../../packages/types" }, 15 | { "path": "../../packages/workflow-engine" } 16 | ] 17 | } 18 | 19 | -------------------------------------------------------------------------------- /apps/web/src/main.ts: -------------------------------------------------------------------------------- 1 | import './workflow-editor.css'; 2 | 3 | import WorkflowEditor from './app/workflow-editor'; 4 | import { HelpModal } from './components/help-modal'; 5 | import { helpContent } from './data/help-content'; 6 | 7 | declare global { 8 | interface Window { 9 | editor?: WorkflowEditor; 10 | } 11 | } 12 | 13 | document.addEventListener('DOMContentLoaded', () => { 14 | window.editor = new WorkflowEditor(); 15 | HelpModal.init({ content: helpContent }); 16 | }); 17 | -------------------------------------------------------------------------------- /packages/types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@agentic/types", 3 | "version": "1.0.0", 4 | "private": false, 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "build": "tsc -b tsconfig.json", 12 | "clean": "rm -rf dist tsconfig.tsbuildinfo", 13 | "dev": "tsc -w -p tsconfig.json", 14 | "typecheck": "tsc -p tsconfig.json --noEmit", 15 | "test": "npm run typecheck" 16 | }, 17 | "license": "MIT" 18 | } 19 | 20 | -------------------------------------------------------------------------------- /apps/server/src/store/active-workflows.ts: -------------------------------------------------------------------------------- 1 | import type WorkflowEngine from '@agentic/workflow-engine'; 2 | 3 | const workflows = new Map(); 4 | 5 | export function addWorkflow(engine: WorkflowEngine): void { 6 | workflows.set(engine.getRunId(), engine); 7 | } 8 | 9 | export function getWorkflow(runId: string): WorkflowEngine | undefined { 10 | return workflows.get(runId); 11 | } 12 | 13 | export function removeWorkflow(runId: string): void { 14 | workflows.delete(runId); 15 | } 16 | 17 | -------------------------------------------------------------------------------- /packages/workflow-engine/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@agentic/workflow-engine", 3 | "version": "1.0.0", 4 | "private": false, 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "build": "tsc -b tsconfig.json", 12 | "clean": "rm -rf dist tsconfig.tsbuildinfo", 13 | "dev": "tsc -w -p tsconfig.json", 14 | "test": "npm run typecheck", 15 | "typecheck": "tsc -p tsconfig.json --noEmit" 16 | }, 17 | "license": "MIT", 18 | "dependencies": { 19 | "@agentic/types": "file:../types" 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /apps/server/src/config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import dotenv from 'dotenv'; 4 | 5 | dotenv.config(); 6 | 7 | const FALLBACK_ROOT = path.resolve(__dirname, '..', '..', '..'); 8 | const PROJECT_ROOT = process.env.PROJECT_ROOT || FALLBACK_ROOT; 9 | const RUNS_DIR = path.resolve(PROJECT_ROOT, 'data', 'runs'); 10 | 11 | fs.mkdirSync(RUNS_DIR, { recursive: true }); 12 | 13 | export const config = { 14 | port: Number(process.env.PORT ?? 3000), 15 | runsDir: RUNS_DIR, 16 | projectRoot: PROJECT_ROOT, 17 | openAiApiKey: process.env.OPENAI_API_KEY ?? '' 18 | }; 19 | 20 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@agentic/web", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "clean": "rm -rf dist", 9 | "build": "tsc -p tsconfig.json --noEmit && vite build", 10 | "preview": "vite preview", 11 | "typecheck": "tsc -p tsconfig.json --noEmit", 12 | "test": "vitest" 13 | }, 14 | "dependencies": { 15 | "@agentic/types": "file:../../packages/types" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^22.8.4", 19 | "typescript": "^5.6.3", 20 | "vite": "^5.4.10", 21 | "vitest": "^2.1.4" 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "CommonJS", 5 | "moduleResolution": "Node", 6 | "lib": [ 7 | "ES2020", 8 | "DOM" 9 | ], 10 | "allowJs": false, 11 | "esModuleInterop": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "resolveJsonModule": true, 14 | "skipLibCheck": true, 15 | "strict": true, 16 | "baseUrl": ".", 17 | "paths": { 18 | "@agentic/types": [ 19 | "./packages/types/src" 20 | ], 21 | "@agentic/workflow-engine": [ 22 | "./packages/workflow-engine/src" 23 | ] 24 | }, 25 | "declarationMap": true 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "moduleResolution": "Bundler", 7 | "lib": [ 8 | "ES2020", 9 | "DOM", 10 | "DOM.Iterable" 11 | ], 12 | "allowJs": false, 13 | "esModuleInterop": true, 14 | "types": [ 15 | "vite/client" 16 | ], 17 | "baseUrl": ".", 18 | "paths": { 19 | "@agentic/types": [ 20 | "../../packages/types/src" 21 | ] 22 | }, 23 | "declaration": false, 24 | "declarationMap": false 25 | }, 26 | "include": [ 27 | "src" 28 | ], 29 | "references": [ 30 | { 31 | "path": "./tsconfig.node.json" 32 | } 33 | ] 34 | } 35 | 36 | -------------------------------------------------------------------------------- /apps/web/src/services/api.ts: -------------------------------------------------------------------------------- 1 | import type { ApprovalInput, WorkflowGraph, WorkflowRunResult } from '@agentic/types'; 2 | 3 | async function request(url: string, body: unknown): Promise { 4 | const res = await fetch(url, { 5 | method: 'POST', 6 | headers: { 'Content-Type': 'application/json' }, 7 | body: JSON.stringify(body) 8 | }); 9 | 10 | if (!res.ok) { 11 | const text = await res.text(); 12 | throw new Error(text || 'Request failed'); 13 | } 14 | 15 | return res.json() as Promise; 16 | } 17 | 18 | export function runWorkflow(graph: WorkflowGraph): Promise { 19 | return request('/api/run', { graph }); 20 | } 21 | 22 | export function resumeWorkflow(runId: string, input: ApprovalInput): Promise { 23 | return request('/api/resume', { runId, input }); 24 | } 25 | 26 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tseslint from '@typescript-eslint/eslint-plugin'; 2 | import tsParser from '@typescript-eslint/parser'; 3 | 4 | export default [ 5 | { 6 | ignores: [ 7 | '**/node_modules/**', 8 | '**/dist/**', 9 | 'data/runs/**', 10 | 'apps/web/public/**' 11 | ] 12 | }, 13 | { 14 | files: ['**/*.ts', '**/*.tsx'], 15 | languageOptions: { 16 | parser: tsParser, 17 | parserOptions: { 18 | ecmaVersion: 2021, 19 | sourceType: 'module' 20 | } 21 | }, 22 | plugins: { 23 | '@typescript-eslint': tseslint 24 | }, 25 | rules: { 26 | 'no-unused-vars': 'off', 27 | '@typescript-eslint/no-unused-vars': [ 28 | 'warn', 29 | { argsIgnorePattern: '^_', varsIgnorePattern: '^_' } 30 | ], 31 | '@typescript-eslint/consistent-type-imports': 'error' 32 | } 33 | } 34 | ]; 35 | -------------------------------------------------------------------------------- /apps/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@agentic/server", 3 | "version": "1.0.0", 4 | "private": true, 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "dev": "tsx watch --ignore ../web --ignore ../../apps/web --ignore ../../data src/index.ts", 8 | "build": "tsc -b tsconfig.json", 9 | "clean": "rm -rf dist tsconfig.tsbuildinfo", 10 | "start": "NODE_ENV=production node dist/index.js", 11 | "typecheck": "tsc -p tsconfig.json --noEmit", 12 | "test": "npm run typecheck" 13 | }, 14 | "dependencies": { 15 | "@agentic/types": "file:../../packages/types", 16 | "@agentic/workflow-engine": "file:../../packages/workflow-engine", 17 | "cors": "^2.8.5", 18 | "dotenv": "^17.2.3", 19 | "express": "^4.19.2", 20 | "openai": "^6.9.1" 21 | }, 22 | "devDependencies": { 23 | "@types/cors": "^2.8.17", 24 | "@types/express": "^4.17.21" 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR Checks 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | push: 7 | branches: [ main ] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build-and-test: 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [20.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | submodules: recursive 22 | 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: 'npm' 28 | 29 | - name: Install dependencies 30 | run: npm ci 31 | 32 | - name: Lint 33 | run: npm run lint 34 | 35 | - name: Build Packages 36 | run: npm run build:packages 37 | 38 | - name: Typecheck 39 | run: npm run typecheck 40 | 41 | - name: Build 42 | run: npm run build 43 | 44 | -------------------------------------------------------------------------------- /packages/types/src/index.d.ts: -------------------------------------------------------------------------------- 1 | export type NodeType = 'start' | 'agent' | 'if' | 'approval' | 'end' | string; 2 | export interface BaseNodeData { 3 | collapsed?: boolean; 4 | [key: string]: unknown; 5 | } 6 | export interface WorkflowNode { 7 | id: string; 8 | type: NodeType; 9 | x: number; 10 | y: number; 11 | data?: TData; 12 | } 13 | export interface WorkflowConnection { 14 | source: string; 15 | target: string; 16 | sourceHandle?: string; 17 | targetHandle?: string; 18 | } 19 | export interface WorkflowGraph { 20 | nodes: WorkflowNode[]; 21 | connections: WorkflowConnection[]; 22 | } 23 | export type WorkflowStatus = 'pending' | 'running' | 'paused' | 'completed' | 'failed'; 24 | export interface WorkflowLogEntry { 25 | timestamp: string; 26 | nodeId: string | 'system'; 27 | type: string; 28 | content: string; 29 | } 30 | export interface WorkflowRunResult { 31 | runId: string; 32 | status: WorkflowStatus; 33 | logs: WorkflowLogEntry[]; 34 | state: Record; 35 | waitingForInput: boolean; 36 | currentNodeId: string | null; 37 | } 38 | export interface ApprovalInput { 39 | decision: 'approve' | 'reject'; 40 | note?: string; 41 | } 42 | export interface WorkflowEngineResult extends WorkflowRunResult { 43 | } 44 | -------------------------------------------------------------------------------- /packages/types/src/index.ts: -------------------------------------------------------------------------------- 1 | export type NodeType = 'start' | 'agent' | 'if' | 'approval' | 'end' | string; 2 | 3 | export interface BaseNodeData { 4 | collapsed?: boolean; 5 | [key: string]: unknown; 6 | } 7 | 8 | export interface WorkflowNode { 9 | id: string; 10 | type: NodeType; 11 | x: number; 12 | y: number; 13 | data?: TData; 14 | } 15 | 16 | export interface WorkflowConnection { 17 | source: string; 18 | target: string; 19 | sourceHandle?: string; 20 | targetHandle?: string; 21 | } 22 | 23 | export interface WorkflowGraph { 24 | nodes: WorkflowNode[]; 25 | connections: WorkflowConnection[]; 26 | } 27 | 28 | export type WorkflowStatus = 'pending' | 'running' | 'paused' | 'completed' | 'failed'; 29 | 30 | export interface WorkflowLogEntry { 31 | timestamp: string; 32 | nodeId: string | 'system'; 33 | type: string; 34 | content: string; 35 | } 36 | 37 | export interface WorkflowRunResult { 38 | runId: string; 39 | status: WorkflowStatus; 40 | logs: WorkflowLogEntry[]; 41 | state: Record; 42 | waitingForInput: boolean; 43 | currentNodeId: string | null; 44 | } 45 | 46 | export interface WorkflowRunRecord { 47 | runId: string; 48 | workflow: WorkflowGraph; 49 | logs: WorkflowLogEntry[]; 50 | status: WorkflowStatus; 51 | } 52 | 53 | export interface ApprovalInput { 54 | decision: 'approve' | 'reject'; 55 | note?: string; 56 | } 57 | 58 | export interface WorkflowEngineResult extends WorkflowRunResult {} 59 | 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "learn_agentic-workflows", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "Agentic Workflow Builder monorepo (web + server + shared packages)", 6 | "license": "MIT", 7 | "workspaces": [ 8 | "apps/*", 9 | "packages/*" 10 | ], 11 | "scripts": { 12 | "dev": "npm --workspace apps/server run dev", 13 | "dev:server": "npm --workspace apps/server run dev", 14 | "dev:web": "npm --workspace apps/web run dev", 15 | "start": "npm --workspace apps/server run start", 16 | "build": "npm run build:server && npm run build:web", 17 | "build:packages": "npm --workspace packages/types run build && npm --workspace packages/workflow-engine run build", 18 | "build:server": "npm run build:packages && npm --workspace apps/server run build", 19 | "build:web": "npm --workspace apps/web run build", 20 | "test": "npm run test:engine && npm run test:server", 21 | "test:engine": "npm --workspace packages/workflow-engine run test", 22 | "test:server": "npm --workspace apps/server run test", 23 | "clean": "npm run clean --workspaces", 24 | "lint": "eslint .", 25 | "typecheck": "npm run typecheck:server && npm run typecheck:web", 26 | "typecheck:server": "npm --workspace apps/server run typecheck", 27 | "typecheck:web": "npm --workspace apps/web run typecheck" 28 | }, 29 | "devDependencies": { 30 | "@typescript-eslint/eslint-plugin": "^8.14.0", 31 | "@typescript-eslint/parser": "^8.14.0", 32 | "concurrently": "^9.1.0", 33 | "eslint": "^9.14.0", 34 | "typescript": "^5.6.3", 35 | "tsx": "^4.19.2" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /apps/server/src/services/openai-llm.ts: -------------------------------------------------------------------------------- 1 | import type OpenAI from 'openai'; 2 | import type { AgentInvocation, WorkflowLLM } from '@agentic/workflow-engine'; 3 | 4 | function formatInput(invocation: AgentInvocation) { 5 | return [ 6 | { 7 | role: 'system', 8 | content: [ 9 | { 10 | type: 'input_text', 11 | text: invocation.systemPrompt 12 | } 13 | ] 14 | }, 15 | { 16 | role: 'user', 17 | content: [ 18 | { 19 | type: 'input_text', 20 | text: invocation.userContent 21 | } 22 | ] 23 | } 24 | ]; 25 | } 26 | 27 | function extractText(response: any): string { 28 | if (Array.isArray(response.output_text) && response.output_text.length > 0) { 29 | return response.output_text.join('\n').trim(); 30 | } 31 | 32 | if (Array.isArray(response.output)) { 33 | const chunks: string[] = []; 34 | response.output.forEach((entry: any) => { 35 | if (entry.type === 'message' && Array.isArray(entry.content)) { 36 | entry.content.forEach((chunk: any) => { 37 | if (chunk.type === 'output_text' && chunk.text) { 38 | chunks.push(chunk.text); 39 | } 40 | }); 41 | } 42 | }); 43 | if (chunks.length > 0) { 44 | return chunks.join('\n').trim(); 45 | } 46 | } 47 | 48 | return 'Model returned no text output.'; 49 | } 50 | 51 | export class OpenAILLMService implements WorkflowLLM { 52 | constructor(private readonly client: OpenAI) {} 53 | 54 | async respond(invocation: AgentInvocation): Promise { 55 | const params: Record = { 56 | model: invocation.model, 57 | input: formatInput(invocation) 58 | }; 59 | 60 | if (invocation.reasoningEffort) { 61 | params.reasoning = { effort: invocation.reasoningEffort }; 62 | } 63 | 64 | if (invocation.tools?.web_search) { 65 | params.tools = [{ type: 'web_search' }]; 66 | params.tool_choice = 'auto'; 67 | } 68 | 69 | const response = await this.client.responses.create(params); 70 | return extractText(response); 71 | } 72 | } 73 | 74 | -------------------------------------------------------------------------------- /apps/web/src/data/help-content.ts: -------------------------------------------------------------------------------- 1 | export const helpContent = ` 2 | 12 | 13 |
14 |

Overview

15 |

The Agentic Workflow Builder lets you compose agent flows with Start, Agent, If/Else, Approval, and End nodes. Drag nodes, connect them, configure prompts, and run against the server-side workflow engine.

16 |
17 | 18 |
19 |

Getting Started

20 |
    21 |
  1. Drag a Start node (auto-added) and at least one Agent node onto the canvas.
  2. 22 |
  3. Connect nodes by dragging from an output port to an input port.
  4. 23 |
  5. Open node settings (gear icon) to configure prompts, models, and approvals.
  6. 24 |
  7. Enter the initial user prompt in the Run Console and click Run Workflow.
  8. 25 |
26 |
27 | 28 |
29 |

Key Features

30 |

Visual Canvas

31 |

Pannable/zoomable canvas with SVG connections, floating palette, and snap-friendly controls.

32 |

Inline Node Editing

33 |

Expand a node to edit prompts, branching conditions, approval text, and tool toggles.

34 |

Run Console

35 |

Chat-style log with status indicator, agent spinner, and approval prompts when workflows pause for review.

36 |
37 | 38 |
39 |

Workflow

40 |
    41 |
  1. Design: Add nodes, wire edges, and double-check that the Start node connects to your flow.
  2. 42 |
  3. Configure: Provide model settings, prompts, and optional tools per agent node.
  4. 43 |
  5. Run: Click Run Workflow. The console streams logs from the server.
  6. 44 |
  7. Approve: When approval nodes are reached, respond with Approve or Reject to continue.
  8. 45 |
46 |
47 | 48 |
49 |

FAQ

50 |
51 | Why does a workflow pause? 52 |

Approval nodes intentionally pause execution until you make a decision in the Run Console.

53 |
54 |
55 | Where are run logs stored? 56 |

Each execution is saved under data/runs/run_*.json for later auditing.

57 |
58 |
59 | Do I need an OpenAI key? 60 |

No. Without OPENAI_API_KEY, the engine returns mock responses. Provide the key for live model calls.

61 |
62 |
63 | `; 64 | 65 | -------------------------------------------------------------------------------- /apps/server/src/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import express, { type Request, type Response } from 'express'; 4 | import cors from 'cors'; 5 | import OpenAI from 'openai'; 6 | import { config } from './config'; 7 | import { logger } from './logger'; 8 | import { createWorkflowRouter } from './routes/workflows'; 9 | import { OpenAILLMService } from './services/openai-llm'; 10 | 11 | const isProduction = process.env.NODE_ENV === 'production'; 12 | const webRoot = path.resolve(__dirname, '../../web'); 13 | const webDist = path.join(webRoot, 'dist'); 14 | 15 | async function bootstrap() { 16 | const app = express(); 17 | app.use(cors()); 18 | app.use(express.json({ limit: '1mb' })); 19 | 20 | let llmService: OpenAILLMService | undefined; 21 | if (config.openAiApiKey) { 22 | logger.info('OPENAI_API_KEY detected, enabling live OpenAI responses'); 23 | const client = new OpenAI({ apiKey: config.openAiApiKey }); 24 | llmService = new OpenAILLMService(client); 25 | } else { 26 | logger.warn('OPENAI_API_KEY missing. Falling back to mock LLM responses.'); 27 | } 28 | 29 | app.use('/api', createWorkflowRouter(llmService)); 30 | 31 | if (isProduction) { 32 | if (fs.existsSync(webDist)) { 33 | app.use(express.static(webDist)); 34 | app.get('*', (_req: Request, res: Response) => { 35 | res.sendFile(path.join(webDist, 'index.html')); 36 | }); 37 | } else { 38 | logger.warn('Built web assets missing. Run `npm run build:web` before starting in production.'); 39 | } 40 | } else { 41 | const fsPromises = fs.promises; 42 | const { createServer: createViteServer } = await import('vite'); 43 | const vite = await createViteServer({ 44 | root: webRoot, 45 | configFile: path.join(webRoot, 'vite.config.ts'), 46 | server: { middlewareMode: true }, 47 | appType: 'custom' 48 | }); 49 | app.use(vite.middlewares); 50 | app.use('*', async (req: Request, res: Response, next) => { 51 | const isHtmlRequest = 52 | req.method === 'GET' && 53 | !req.originalUrl.startsWith('/api') && 54 | !req.originalUrl.includes('.') && 55 | req.headers.accept?.includes('text/html'); 56 | 57 | if (!isHtmlRequest) { 58 | next(); 59 | return; 60 | } 61 | 62 | try { 63 | const url = req.originalUrl; 64 | const templatePath = path.join(webRoot, 'index.html'); 65 | let template = await fsPromises.readFile(templatePath, 'utf-8'); 66 | template = await vite.transformIndexHtml(url, template); 67 | res.status(200).set({ 'Content-Type': 'text/html' }).end(template); 68 | } catch (error) { 69 | vite.ssrFixStacktrace(error as Error); 70 | next(error); 71 | } 72 | }); 73 | logger.info('Vite dev middleware attached. UI available at http://localhost:%d', config.port); 74 | } 75 | 76 | app.listen(config.port, () => { 77 | logger.info(`Server listening on http://localhost:${config.port}`); 78 | }); 79 | } 80 | 81 | bootstrap().catch((error) => { 82 | logger.error('Failed to start server', error); 83 | process.exitCode = 1; 84 | }); 85 | 86 | -------------------------------------------------------------------------------- /apps/web/src/components/help-modal.ts: -------------------------------------------------------------------------------- 1 | export interface HelpModalOptions { 2 | triggerSelector?: string; 3 | content?: string; 4 | theme?: 'auto' | 'light' | 'dark'; 5 | } 6 | 7 | export class HelpModal { 8 | private options: Required; 9 | 10 | private isOpen = false; 11 | 12 | private modal: HTMLDivElement; 13 | 14 | private trigger: HTMLElement | null = null; 15 | 16 | constructor(options: HelpModalOptions = {}) { 17 | this.options = { 18 | triggerSelector: '#btn-help', 19 | content: '', 20 | theme: 'auto', 21 | ...options 22 | }; 23 | 24 | this.modal = this.createModal(); 25 | this.bindEvents(); 26 | } 27 | 28 | static init(options: HelpModalOptions) { 29 | return new HelpModal(options); 30 | } 31 | 32 | open() { 33 | if (this.isOpen) return; 34 | this.isOpen = true; 35 | this.modal.style.display = 'flex'; 36 | document.body.style.overflow = 'hidden'; 37 | const closeBtn = this.modal.querySelector('.modal-close'); 38 | closeBtn?.focus(); 39 | this.trigger?.dispatchEvent(new CustomEvent('helpModal:open', { detail: this })); 40 | } 41 | 42 | close() { 43 | if (!this.isOpen) return; 44 | this.isOpen = false; 45 | this.modal.style.display = 'none'; 46 | document.body.style.overflow = ''; 47 | this.trigger?.focus(); 48 | this.trigger?.dispatchEvent(new CustomEvent('helpModal:close', { detail: this })); 49 | } 50 | 51 | updateContent(content: string) { 52 | const body = this.modal.querySelector('.modal-body'); 53 | if (body) { 54 | body.innerHTML = content; 55 | } 56 | } 57 | 58 | private createModal(): HTMLDivElement { 59 | const modal = document.createElement('div'); 60 | modal.className = 'modal'; 61 | modal.style.display = 'none'; 62 | modal.innerHTML = ` 63 | 64 | 71 | `; 72 | document.body.appendChild(modal); 73 | return modal; 74 | } 75 | 76 | private bindEvents() { 77 | this.trigger = document.querySelector(this.options.triggerSelector); 78 | if (!this.trigger) { 79 | console.warn(`Help trigger "${this.options.triggerSelector}" not found`); 80 | return; 81 | } 82 | 83 | this.trigger.addEventListener('click', (event) => { 84 | event.preventDefault(); 85 | this.open(); 86 | }); 87 | 88 | const closeBtn = this.modal.querySelector('.modal-close'); 89 | closeBtn?.addEventListener('click', () => this.close()); 90 | 91 | const backdrop = this.modal.querySelector('.modal-backdrop'); 92 | backdrop?.addEventListener('click', () => this.close()); 93 | 94 | document.addEventListener('keydown', (event) => { 95 | if (event.key === 'Escape' && this.isOpen) { 96 | this.close(); 97 | } 98 | }); 99 | 100 | this.modal.addEventListener('click', (event) => { 101 | const target = event.target as HTMLElement; 102 | if (target.matches('a[href^="#"]')) { 103 | event.preventDefault(); 104 | const id = target.getAttribute('href')?.substring(1); 105 | const section = id ? this.modal.querySelector(`#${id}`) : null; 106 | section?.scrollIntoView({ behavior: 'smooth' }); 107 | } 108 | }); 109 | } 110 | } 111 | 112 | -------------------------------------------------------------------------------- /apps/server/src/routes/workflows.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, Router } from 'express'; 2 | import { Router as createRouter } from 'express'; 3 | import type { WorkflowGraph, WorkflowRunRecord, WorkflowRunResult } from '@agentic/types'; 4 | import type { WorkflowLLM } from '@agentic/workflow-engine'; 5 | import WorkflowEngine from '@agentic/workflow-engine'; 6 | import { addWorkflow, getWorkflow, removeWorkflow } from '../store/active-workflows'; 7 | import { saveRunRecord } from '../services/persistence'; 8 | import { config } from '../config'; 9 | import { logger } from '../logger'; 10 | 11 | function validateGraph(graph: WorkflowGraph | undefined): graph is WorkflowGraph { 12 | return Boolean(graph && Array.isArray(graph.nodes) && Array.isArray(graph.connections)); 13 | } 14 | 15 | async function persistResult(engine: WorkflowEngine, result: WorkflowRunResult) { 16 | try { 17 | // Backward compatibility: fall back to reading the private graph field if the engine 18 | // instance doesn't yet expose getGraph (e.g., cached build). 19 | const engineAny = engine as WorkflowEngine & { getGraph?: () => WorkflowGraph }; 20 | const workflow = 21 | typeof engineAny.getGraph === 'function' 22 | ? engineAny.getGraph() 23 | : (Reflect.get(engine, 'graph') as WorkflowGraph | undefined); 24 | 25 | if (!workflow) { 26 | throw new Error('Workflow graph not available on engine instance'); 27 | } 28 | 29 | const record: WorkflowRunRecord = { 30 | runId: result.runId, 31 | workflow, 32 | logs: result.logs, 33 | status: result.status 34 | }; 35 | 36 | await saveRunRecord(config.runsDir, record); 37 | } catch (error) { 38 | logger.error('Failed to persist run result', error); 39 | } 40 | } 41 | 42 | export function createWorkflowRouter(llm?: WorkflowLLM): Router { 43 | const router = createRouter(); 44 | 45 | router.post('/run', async (req: Request, res: Response) => { 46 | const { graph } = req.body as { graph?: WorkflowGraph }; 47 | 48 | if (!validateGraph(graph)) { 49 | res.status(400).json({ error: 'Invalid workflow graph payload' }); 50 | return; 51 | } 52 | 53 | try { 54 | const runId = Date.now().toString(); 55 | const engine = new WorkflowEngine(graph, { runId, llm }); 56 | addWorkflow(engine); 57 | 58 | const result = await engine.run(); 59 | await persistResult(engine, result); 60 | 61 | res.json(result); 62 | } catch (error) { 63 | const message = error instanceof Error ? error.message : String(error); 64 | logger.error('Failed to execute workflow', message); 65 | res.status(500).json({ error: 'Failed to execute workflow', details: message }); 66 | } 67 | }); 68 | 69 | router.post('/resume', async (req: Request, res: Response) => { 70 | const { runId, input } = req.body as { runId?: string; input?: unknown }; 71 | if (!runId) { 72 | res.status(400).json({ error: 'runId is required' }); 73 | return; 74 | } 75 | 76 | const engine = getWorkflow(runId); 77 | if (!engine) { 78 | res.status(404).json({ error: 'Run ID not found' }); 79 | return; 80 | } 81 | 82 | try { 83 | const result = await engine.resume(input as Record); 84 | await persistResult(engine, result); 85 | 86 | if (result.status !== 'paused') { 87 | removeWorkflow(runId); 88 | } 89 | 90 | res.json(result); 91 | } catch (error) { 92 | const message = error instanceof Error ? error.message : String(error); 93 | logger.error('Failed to resume workflow', message); 94 | res.status(500).json({ error: 'Failed to resume workflow', details: message }); 95 | } 96 | }); 97 | 98 | return router; 99 | } 100 | 101 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Elastic License 2.0 2 | 3 | URL: https://www.elastic.co/licensing/elastic-license 4 | 5 | ## Acceptance 6 | 7 | By using the software, you agree to all of the terms and conditions below. 8 | 9 | ## Copyright License 10 | 11 | The licensor grants you a non-exclusive, royalty-free, worldwide, 12 | non-sublicensable, non-transferable license to use, copy, distribute, make 13 | available, and prepare derivative works of the software, in each case subject to 14 | the limitations and conditions below. 15 | 16 | ## Limitations 17 | 18 | You may not provide the software to third parties as a hosted or managed 19 | service, where the service provides users with access to any substantial set of 20 | the features or functionality of the software. 21 | 22 | You may not move, change, disable, or circumvent the license key functionality 23 | in the software, and you may not remove or obscure any functionality in the 24 | software that is protected by the license key. 25 | 26 | You may not alter, remove, or obscure any licensing, copyright, or other notices 27 | of the licensor in the software. Any use of the licensor’s trademarks is subject 28 | to applicable law. 29 | 30 | ## Patents 31 | 32 | The licensor grants you a license, under any patent claims the licensor can 33 | license, or becomes able to license, to make, have made, use, sell, offer for 34 | sale, import and have imported the software, in each case subject to the 35 | limitations and conditions in this license. This license does not cover any 36 | patent claims that you cause to be infringed by modifications or additions to 37 | the software. If you or your company make any written claim that the software 38 | infringes or contributes to infringement of any patent, your patent license for 39 | the software granted under these terms ends immediately. If your company makes 40 | such a claim, your patent license ends immediately for work on behalf of your 41 | company. 42 | 43 | ## Notices 44 | 45 | You must ensure that anyone who gets a copy of any part of the software from you 46 | also gets a copy of these terms. 47 | 48 | If you modify the software, you must include in any modified copies of the 49 | software prominent notices stating that you have modified the software. 50 | 51 | ## No Other Rights 52 | 53 | These terms do not imply any licenses other than those expressly granted in 54 | these terms. 55 | 56 | ## Termination 57 | 58 | If you use the software in violation of these terms, such use is not licensed, 59 | and your licenses will automatically terminate. If the licensor provides you 60 | with a notice of your violation, and you cease all violation of this license no 61 | later than 30 days after you receive that notice, your licenses will be 62 | reinstated retroactively. However, if you violate these terms after such 63 | reinstatement, any additional violation of these terms will cause your licenses 64 | to terminate automatically and permanently. 65 | 66 | ## No Liability 67 | 68 | *As far as the law allows, the software comes as is, without any warranty or 69 | condition, and the licensor will not be liable to you for any damages arising 70 | out of these terms or the use or nature of the software, under any kind of 71 | legal claim.* 72 | 73 | ## Definitions 74 | 75 | The **licensor** is the entity offering these terms, and the **software** is the 76 | software the licensor makes available under these terms, including any portion 77 | of it. 78 | 79 | **you** refers to the individual or entity agreeing to these terms. 80 | 81 | **your company** is any legal entity, sole proprietorship, or other kind of 82 | organization that you work for, plus all organizations that have control over, 83 | are under the control of, or are under common control with that 84 | organization. **control** means ownership of substantially all the assets of an 85 | entity, or the power to direct its management and policies by vote, contract, or 86 | otherwise. Control can be direct or indirect. 87 | 88 | **your licenses** are all the licenses granted to you for the software under 89 | these terms. 90 | 91 | **use** means anything you do with the software requiring one of your licenses. 92 | 93 | **trademark** means trademarks, service marks, and similar rights. 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Agentic Workflow Builder 2 | 3 | Agentic Workflow Builder is a web app for visually composing, executing, and auditing LLM workflows. Drag Start, Agent, If/Else, Approval, and End nodes onto the canvas, connect them with Bezier edges, configure prompts inline, and run the flow through a server-side engine that records every step for later review. 4 | 5 | ## Repository Layout 6 | 7 | ``` 8 | apps/ 9 | server/ # Express + Vite middleware; REST API + static delivery 10 | web/ # Vite UI (TypeScript + CodeSignal design system) 11 | packages/ 12 | types/ # Shared TypeScript contracts (nodes, graphs, run logs) 13 | workflow-engine/ # Reusable workflow executor w/ pluggable LLM interface 14 | data/ 15 | runs/ # JSON snapshots of each workflow execution 16 | ``` 17 | 18 | ## Key Features 19 | 20 | - **Visual Editor** – Canvas, floating palette, zoom controls, and inline node forms for prompts, branching rules, and approval copy. 21 | - **Run Console** – Chat-style stream that differentiates user prompts, agent turns, spinner states, and approval requests. 22 | - **Workflow Engine** – Handles graph traversal, approvals, and LLM invocation (OpenAI Responses API or mock). 23 | - **Persistent Audit Trail** – Every run writes `data/runs/run_.json` containing the workflow graph plus raw execution logs, independent of what the UI chooses to display. 24 | 25 | ## Getting Started 26 | 27 | 1. Install dependencies: 28 | ```bash 29 | npm install 30 | ``` 31 | 2. (Optional) create `.env` with `OPENAI_API_KEY=sk-...`. Without it the engine falls back to deterministic mock responses. 32 | 3. Start the integrated dev server (Express + embedded Vite middleware on one port): 33 | ```bash 34 | npm run dev 35 | ``` 36 | Open `http://localhost:3000` for the UI; APIs live under `/api`. 37 | 4. Production build: 38 | ```bash 39 | npm run build 40 | ``` 41 | 42 | ### Script Reference 43 | 44 | | Script | Purpose | 45 | | --- | --- | 46 | | `npm run dev` | Express server with Vite middleware (single origin on port 3000). | 47 | | `npm run dev:server` | Same as `dev`; useful if you only need the backend. | 48 | | `npm run dev:web` | Standalone Vite dev server on 5173 (talks to `/api` proxy). | 49 | | `npm run build` | Build shared packages, server, and web bundle. | 50 | | `npm run build:packages` | Rebuild `packages/types` and `packages/workflow-engine`. | 51 | | `npm run build:server` / `npm run build:web` | Targeted builds. | 52 | | `npm run lint` | ESLint via the repo-level config. | 53 | | `npm run typecheck` | TypeScript in both apps. | 54 | 55 | ## Architecture Notes 56 | 57 | - **`@agentic/workflow-engine`**: Pure TypeScript package that normalizes graphs, manages state, pauses for approvals, and calls an injected `WorkflowLLM`. It now exposes `getGraph()` so callers can persist what actually ran. 58 | - **Server (`apps/server`)**: Express routes `/api/run` + `/api/resume` hydrate `WorkflowEngine` instances, fallback to mock LLMs when no OpenAI key is present, and persist run records through `saveRunRecord()` into `data/runs/`. 59 | - **Web (`apps/web`)**: Vite SPA using the CodeSignal design system. Core UI logic lives in `src/app/workflow-editor.ts`; shared helpers (help modal, API client, etc.) live under `src/`. 60 | - **Shared contracts**: `packages/types` keeps node shapes, graph schemas, log formats, and run-record definitions in sync across the stack. 61 | 62 | ## Design System Usage (web) 63 | 64 | - The CodeSignal design system lives as a git submodule at `design-system/` and is served statically at `/design-system/*` via the `apps/web/public/design-system` symlink. 65 | - Foundations and components are linked in `apps/web/index.html` (colors, spacing, typography, buttons, icons, inputs, dropdowns). 66 | - Dropdowns in the editor use the design-system JS component, dynamically imported from `/design-system/components/dropdown/dropdown.js`. 67 | - All bespoke CSS has been removed; remaining styling in `apps/web/src/workflow-editor.css` uses design-system tokens and classes. 68 | 69 | ## Run Records 70 | 71 | Every successful or paused execution produces: 72 | 73 | ```json 74 | { 75 | "runId": "1763679127679", 76 | "workflow": { "nodes": [...], "connections": [...] }, 77 | "logs": [ 78 | { "timestamp": "...", "nodeId": "node_agent", "type": "llm_response", "content": "..." } 79 | ], 80 | "status": "completed" 81 | } 82 | ``` 83 | 84 | Files live in `data/runs/` and can be used for grading, replay, or export pipelines. These are intentionally more detailed than the UI console (which may apply formatting or filtering). 85 | 86 | ## License 87 | 88 | This repository ships under the **Elastic License 2.0** (see `LICENSE`). You must comply with its terms—MIT references elsewhere were outdated and have been corrected. 89 | -------------------------------------------------------------------------------- /apps/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Agentic Workflow Builder 8 | 9 | 10 | 14 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
32 | 33 |
34 |

Nodes

35 |
36 |
37 | Agent 38 |
39 |
40 | If / Else 41 |
42 |
43 | User Approval 44 |
45 |
46 | End 47 |
48 |
49 |
50 |
51 | 54 | 57 |
58 |
59 | 60 |
61 |
62 |
63 | 64 |
65 | 87 |
88 | 89 | 104 | 105 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /packages/workflow-engine/src/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.WorkflowEngine = void 0; 4 | const DEFAULT_REASONING = 'low'; 5 | class MockLLM { 6 | async respond(input) { 7 | const toolSuffix = input.tools?.web_search ? ' (web search enabled)' : ''; 8 | return `Mock response for "${input.userContent || 'empty prompt'}" using ${input.model}${toolSuffix}.`; 9 | } 10 | } 11 | class WorkflowEngine { 12 | constructor(graph, options = {}) { 13 | this.logs = []; 14 | this.state = {}; 15 | this.status = 'pending'; 16 | this.currentNodeId = null; 17 | this.waitingForInput = false; 18 | this.graph = this.normalizeGraph(graph); 19 | this.runId = options.runId ?? Date.now().toString(); 20 | this.llm = options.llm ?? new MockLLM(); 21 | this.timestampFn = options.timestampFn ?? (() => new Date().toISOString()); 22 | this.onLog = options.onLog; 23 | } 24 | getRunId() { 25 | return this.runId; 26 | } 27 | getLogs() { 28 | return this.logs; 29 | } 30 | getStatus() { 31 | return this.status; 32 | } 33 | getResult() { 34 | return { 35 | runId: this.runId, 36 | status: this.status, 37 | logs: this.logs, 38 | state: this.state, 39 | waitingForInput: this.waitingForInput, 40 | currentNodeId: this.currentNodeId 41 | }; 42 | } 43 | async run() { 44 | this.status = 'running'; 45 | const startNode = this.graph.nodes.find((n) => n.type === 'start'); 46 | if (!startNode) { 47 | this.log('system', 'error', 'No start node found in workflow graph'); 48 | this.status = 'failed'; 49 | return this.getResult(); 50 | } 51 | this.currentNodeId = startNode.id; 52 | await this.processNode(startNode); 53 | return this.getResult(); 54 | } 55 | async resume(input) { 56 | if (this.status !== 'paused' || !this.currentNodeId) { 57 | return this.getResult(); 58 | } 59 | const currentNode = this.graph.nodes.find((n) => n.id === this.currentNodeId); 60 | if (!currentNode) { 61 | this.status = 'failed'; 62 | this.log(this.currentNodeId, 'error', 'Unable to resume, current node missing'); 63 | return this.getResult(); 64 | } 65 | this.waitingForInput = false; 66 | this.status = 'running'; 67 | let connection; 68 | if (currentNode.type === 'approval') { 69 | const normalized = this.normalizeApprovalInput(input); 70 | const logMessage = this.describeApprovalResult(normalized); 71 | this.log(currentNode.id, 'input_received', logMessage); 72 | this.state[`${currentNode.id}_approval`] = normalized; 73 | const restored = this.state.pre_approval_output; 74 | if (restored !== undefined) { 75 | if (typeof restored === 'string') { 76 | this.state.last_output = restored; 77 | } 78 | else { 79 | this.state.last_output = JSON.stringify(restored); 80 | } 81 | } 82 | delete this.state.pre_approval_output; 83 | connection = this.graph.connections.find((c) => c.source === currentNode.id && c.sourceHandle === normalized.decision); 84 | } 85 | else { 86 | this.log(currentNode.id, 'input_received', JSON.stringify(input)); 87 | this.state.last_output = input ?? ''; 88 | connection = this.graph.connections.find((c) => c.source === currentNode.id); 89 | } 90 | if (connection) { 91 | const nextNode = this.graph.nodes.find((n) => n.id === connection.target); 92 | if (nextNode) { 93 | await this.processNode(nextNode); 94 | } 95 | else { 96 | this.status = 'completed'; 97 | } 98 | } 99 | else { 100 | this.status = 'completed'; 101 | } 102 | return this.getResult(); 103 | } 104 | normalizeGraph(graph) { 105 | const nodes = Array.isArray(graph.nodes) 106 | ? graph.nodes.map((node) => { 107 | if (node.type === 'input') { 108 | return { ...node, type: 'approval' }; 109 | } 110 | return node; 111 | }) 112 | : []; 113 | return { 114 | nodes, 115 | connections: Array.isArray(graph.connections) ? graph.connections : [] 116 | }; 117 | } 118 | log(nodeId, type, content) { 119 | const entry = { 120 | timestamp: this.timestampFn(), 121 | nodeId: nodeId ?? 'system', 122 | type, 123 | content 124 | }; 125 | this.logs.push(entry); 126 | if (this.onLog) { 127 | this.onLog(entry); 128 | } 129 | } 130 | async processNode(node) { 131 | this.currentNodeId = node.id; 132 | this.log(node.id, 'step_start', this.describeNode(node)); 133 | try { 134 | let output = null; 135 | switch (node.type) { 136 | case 'start': 137 | output = node.data?.initialInput || ''; 138 | break; 139 | case 'agent': 140 | output = await this.executeAgentNode(node); 141 | break; 142 | case 'if': { 143 | const nextNodeId = this.evaluateIfNode(node); 144 | if (nextNodeId) { 145 | const nextNode = this.graph.nodes.find((n) => n.id === nextNodeId); 146 | if (nextNode) { 147 | await this.processNode(nextNode); 148 | return; 149 | } 150 | } 151 | break; 152 | } 153 | case 'approval': 154 | this.state.pre_approval_output = this.state.last_output; 155 | this.status = 'paused'; 156 | this.waitingForInput = true; 157 | this.log(node.id, 'wait_input', 'Waiting for user approval'); 158 | return; 159 | case 'end': 160 | this.status = 'completed'; 161 | return; 162 | default: 163 | this.log(node.id, 'warn', `Unknown node type "${node.type}" skipped`); 164 | } 165 | this.state.last_output = output; 166 | this.state[node.id] = output; 167 | const nextConnection = this.graph.connections.find((c) => c.source === node.id); 168 | if (nextConnection) { 169 | const nextNode = this.graph.nodes.find((n) => n.id === nextConnection.target); 170 | if (nextNode) { 171 | await this.processNode(nextNode); 172 | } 173 | else { 174 | this.status = 'completed'; 175 | } 176 | } 177 | else if (node.type !== 'end') { 178 | this.status = 'completed'; 179 | } 180 | } 181 | catch (error) { 182 | const message = error instanceof Error ? error.message : String(error); 183 | this.log(node.id, 'error', message); 184 | this.status = 'failed'; 185 | } 186 | } 187 | describeNode(node) { 188 | if (node.type === 'agent') { 189 | const name = node.data?.agentName || 'Agent'; 190 | return `${name} agent node`; 191 | } 192 | switch (node.type) { 193 | case 'start': 194 | return 'start node'; 195 | case 'if': 196 | return 'if/else node'; 197 | case 'approval': 198 | return 'approval node'; 199 | case 'end': 200 | return 'end node'; 201 | default: 202 | return `${node.type} node`; 203 | } 204 | } 205 | evaluateIfNode(node) { 206 | const condition = node.data?.condition || ''; 207 | const input = JSON.stringify(this.state.last_output || ''); 208 | const match = input.toLowerCase().includes(condition.toLowerCase()); 209 | this.log(node.id, 'logic_check', `Condition "${condition}" evaluated as ${match ? 'true' : 'false'}`); 210 | const trueConn = this.graph.connections.find((c) => c.source === node.id && c.sourceHandle === 'true'); 211 | const falseConn = this.graph.connections.find((c) => c.source === node.id && c.sourceHandle === 'false'); 212 | if (match && trueConn) 213 | return trueConn.target; 214 | if (!match && falseConn) 215 | return falseConn.target; 216 | return null; 217 | } 218 | async executeAgentNode(node) { 219 | const previousOutput = this.state.last_output; 220 | let userContent = ''; 221 | if (node.data?.userPrompt && typeof node.data.userPrompt === 'string' && node.data.userPrompt.trim()) { 222 | userContent = node.data.userPrompt; 223 | } 224 | else if (typeof previousOutput === 'string') { 225 | userContent = previousOutput; 226 | } 227 | else if (previousOutput !== undefined && previousOutput !== null) { 228 | userContent = JSON.stringify(previousOutput); 229 | } 230 | if (previousOutput && 231 | typeof previousOutput === 'object' && 232 | ('decision' in previousOutput || 233 | 'note' in previousOutput)) { 234 | const safe = this.findLastNonApprovalOutput(); 235 | userContent = safe || ''; 236 | } 237 | const invocation = { 238 | systemPrompt: node.data?.systemPrompt || 'You are a helpful assistant.', 239 | userContent, 240 | model: node.data?.model || 'gpt-5', 241 | reasoningEffort: node.data?.reasoningEffort || DEFAULT_REASONING, 242 | tools: node.data?.tools 243 | }; 244 | this.log(node.id, 'start_prompt', invocation.userContent || ''); 245 | try { 246 | const responseText = await this.llm.respond(invocation); 247 | this.log(node.id, 'llm_response', responseText); 248 | return responseText; 249 | } 250 | catch (error) { 251 | const message = error instanceof Error ? error.message : String(error); 252 | this.log(node.id, 'llm_error', message); 253 | return `LLM error: ${message}`; 254 | } 255 | } 256 | findLastNonApprovalOutput() { 257 | const entries = Object.entries(this.state); 258 | for (let i = entries.length - 1; i >= 0; i -= 1) { 259 | const [key, value] = entries[i]; 260 | if (key.includes('_approval') || key === 'last_output' || key === 'pre_approval_output') { 261 | continue; 262 | } 263 | if (typeof value === 'string') { 264 | return value; 265 | } 266 | } 267 | return null; 268 | } 269 | normalizeApprovalInput(input) { 270 | if (typeof input === 'string') { 271 | return { 272 | decision: input.toLowerCase().includes('reject') ? 'reject' : 'approve', 273 | note: '' 274 | }; 275 | } 276 | if (input && typeof input === 'object') { 277 | const decision = input.decision === 'reject' || 278 | (typeof input.decision === 'string' && input.decision.toLowerCase() === 'reject') 279 | ? 'reject' 280 | : 'approve'; 281 | return { 282 | decision, 283 | note: typeof input.note === 'string' ? input.note : '' 284 | }; 285 | } 286 | return { decision: 'approve', note: '' }; 287 | } 288 | describeApprovalResult(result) { 289 | const base = result.decision === 'approve' ? 'User approved this step.' : 'User rejected this step.'; 290 | if (result.note && result.note.trim()) { 291 | return `${base} Feedback: ${result.note.trim()}`; 292 | } 293 | return base; 294 | } 295 | } 296 | exports.WorkflowEngine = WorkflowEngine; 297 | exports.default = WorkflowEngine; 298 | -------------------------------------------------------------------------------- /packages/workflow-engine/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ApprovalInput, 3 | WorkflowConnection, 4 | WorkflowGraph, 5 | WorkflowLogEntry, 6 | WorkflowNode, 7 | WorkflowRunResult, 8 | WorkflowStatus 9 | } from '@agentic/types'; 10 | 11 | type AgentToolsConfig = { 12 | web_search?: boolean; 13 | }; 14 | 15 | export interface AgentInvocation { 16 | systemPrompt: string; 17 | userContent: string; 18 | model: string; 19 | reasoningEffort?: string; 20 | tools?: AgentToolsConfig; 21 | } 22 | 23 | export interface WorkflowLLM { 24 | respond: (input: AgentInvocation) => Promise; 25 | } 26 | 27 | export interface WorkflowEngineInitOptions { 28 | runId?: string; 29 | llm?: WorkflowLLM; 30 | timestampFn?: () => string; 31 | onLog?: (entry: WorkflowLogEntry) => void; 32 | } 33 | 34 | const DEFAULT_REASONING = 'low'; 35 | 36 | class MockLLM implements WorkflowLLM { 37 | async respond(input: AgentInvocation): Promise { 38 | const toolSuffix = input.tools?.web_search ? ' (web search enabled)' : ''; 39 | return `Mock response for "${input.userContent || 'empty prompt'}" using ${input.model}${toolSuffix}.`; 40 | } 41 | } 42 | 43 | export class WorkflowEngine { 44 | private readonly runId: string; 45 | 46 | private readonly timestampFn: () => string; 47 | 48 | private readonly onLog?: (entry: WorkflowLogEntry) => void; 49 | 50 | private graph: WorkflowGraph; 51 | 52 | private llm: WorkflowLLM; 53 | 54 | private logs: WorkflowLogEntry[] = []; 55 | 56 | private state: Record = {}; 57 | 58 | private status: WorkflowStatus = 'pending'; 59 | 60 | private currentNodeId: string | null = null; 61 | 62 | private waitingForInput = false; 63 | 64 | constructor(graph: WorkflowGraph, options: WorkflowEngineInitOptions = {}) { 65 | this.graph = this.normalizeGraph(graph); 66 | this.runId = options.runId ?? Date.now().toString(); 67 | this.llm = options.llm ?? new MockLLM(); 68 | this.timestampFn = options.timestampFn ?? (() => new Date().toISOString()); 69 | this.onLog = options.onLog; 70 | } 71 | 72 | getRunId(): string { 73 | return this.runId; 74 | } 75 | 76 | getLogs(): WorkflowLogEntry[] { 77 | return this.logs; 78 | } 79 | 80 | getStatus(): WorkflowStatus { 81 | return this.status; 82 | } 83 | 84 | getGraph(): WorkflowGraph { 85 | return this.graph; 86 | } 87 | 88 | getResult(): WorkflowRunResult { 89 | return { 90 | runId: this.runId, 91 | status: this.status, 92 | logs: this.logs, 93 | state: this.state, 94 | waitingForInput: this.waitingForInput, 95 | currentNodeId: this.currentNodeId 96 | }; 97 | } 98 | 99 | async run(): Promise { 100 | this.status = 'running'; 101 | const startNode = this.graph.nodes.find((n) => n.type === 'start'); 102 | if (!startNode) { 103 | this.log('system', 'error', 'No start node found in workflow graph'); 104 | this.status = 'failed'; 105 | return this.getResult(); 106 | } 107 | 108 | this.currentNodeId = startNode.id; 109 | await this.processNode(startNode); 110 | return this.getResult(); 111 | } 112 | 113 | async resume(input?: ApprovalInput | string | Record): Promise { 114 | if (this.status !== 'paused' || !this.currentNodeId) { 115 | return this.getResult(); 116 | } 117 | 118 | const currentNode = this.graph.nodes.find((n) => n.id === this.currentNodeId); 119 | if (!currentNode) { 120 | this.status = 'failed'; 121 | this.log(this.currentNodeId, 'error', 'Unable to resume, current node missing'); 122 | return this.getResult(); 123 | } 124 | 125 | this.waitingForInput = false; 126 | this.status = 'running'; 127 | 128 | let connection: WorkflowConnection | undefined; 129 | 130 | if (currentNode.type === 'approval') { 131 | const normalized = this.normalizeApprovalInput(input); 132 | const logMessage = this.describeApprovalResult(normalized); 133 | this.log(currentNode.id, 'input_received', logMessage); 134 | this.state[`${currentNode.id}_approval`] = normalized; 135 | 136 | const restored = this.state.pre_approval_output; 137 | if (restored !== undefined) { 138 | if (typeof restored === 'string') { 139 | this.state.last_output = restored; 140 | } else { 141 | this.state.last_output = JSON.stringify(restored); 142 | } 143 | } 144 | delete this.state.pre_approval_output; 145 | connection = this.graph.connections.find( 146 | (c) => c.source === currentNode.id && c.sourceHandle === normalized.decision 147 | ); 148 | } else { 149 | this.log(currentNode.id, 'input_received', JSON.stringify(input)); 150 | this.state.last_output = input ?? ''; 151 | connection = this.graph.connections.find((c) => c.source === currentNode.id); 152 | } 153 | 154 | if (connection) { 155 | const nextNode = this.graph.nodes.find((n) => n.id === connection.target); 156 | if (nextNode) { 157 | await this.processNode(nextNode); 158 | } else { 159 | this.status = 'completed'; 160 | } 161 | } else { 162 | this.status = 'completed'; 163 | } 164 | 165 | return this.getResult(); 166 | } 167 | 168 | private normalizeGraph(graph: WorkflowGraph): WorkflowGraph { 169 | const nodes = Array.isArray(graph.nodes) 170 | ? graph.nodes.map((node) => { 171 | if (node.type === 'input') { 172 | return { ...node, type: 'approval' }; 173 | } 174 | return node; 175 | }) 176 | : []; 177 | return { 178 | nodes, 179 | connections: Array.isArray(graph.connections) ? graph.connections : [] 180 | }; 181 | } 182 | 183 | private log(nodeId: string | null, type: string, content: string): void { 184 | const entry: WorkflowLogEntry = { 185 | timestamp: this.timestampFn(), 186 | nodeId: nodeId ?? 'system', 187 | type, 188 | content 189 | }; 190 | this.logs.push(entry); 191 | if (this.onLog) { 192 | this.onLog(entry); 193 | } 194 | } 195 | 196 | private async processNode(node: WorkflowNode): Promise { 197 | this.currentNodeId = node.id; 198 | this.log(node.id, 'step_start', this.describeNode(node)); 199 | 200 | try { 201 | let output: unknown = null; 202 | 203 | switch (node.type) { 204 | case 'start': 205 | output = node.data?.initialInput || ''; 206 | break; 207 | case 'agent': 208 | output = await this.executeAgentNode(node); 209 | break; 210 | case 'if': { 211 | const nextNodeId = this.evaluateIfNode(node); 212 | if (nextNodeId) { 213 | const nextNode = this.graph.nodes.find((n) => n.id === nextNodeId); 214 | if (nextNode) { 215 | await this.processNode(nextNode); 216 | return; 217 | } 218 | } 219 | break; 220 | } 221 | case 'approval': 222 | this.state.pre_approval_output = this.state.last_output; 223 | this.status = 'paused'; 224 | this.waitingForInput = true; 225 | this.log(node.id, 'wait_input', 'Waiting for user approval'); 226 | return; 227 | case 'end': 228 | this.status = 'completed'; 229 | return; 230 | default: 231 | this.log(node.id, 'warn', `Unknown node type "${node.type}" skipped`); 232 | } 233 | 234 | this.state.last_output = output; 235 | this.state[node.id] = output; 236 | 237 | const nextConnection = this.graph.connections.find((c) => c.source === node.id); 238 | if (nextConnection) { 239 | const nextNode = this.graph.nodes.find((n) => n.id === nextConnection.target); 240 | if (nextNode) { 241 | await this.processNode(nextNode); 242 | } else { 243 | this.status = 'completed'; 244 | } 245 | } else if (node.type !== 'end') { 246 | this.status = 'completed'; 247 | } 248 | } catch (error) { 249 | const message = error instanceof Error ? error.message : String(error); 250 | this.log(node.id, 'error', message); 251 | this.status = 'failed'; 252 | } 253 | } 254 | 255 | private describeNode(node: WorkflowNode): string { 256 | if (node.type === 'agent') { 257 | const name = (node.data?.agentName as string) || 'Agent'; 258 | return `${name} agent node`; 259 | } 260 | switch (node.type) { 261 | case 'start': 262 | return 'start node'; 263 | case 'if': 264 | return 'if/else node'; 265 | case 'approval': 266 | return 'approval node'; 267 | case 'end': 268 | return 'end node'; 269 | default: 270 | return `${node.type} node`; 271 | } 272 | } 273 | 274 | private evaluateIfNode(node: WorkflowNode): string | null { 275 | const condition = (node.data?.condition as string) || ''; 276 | const input = JSON.stringify(this.state.last_output || ''); 277 | const match = input.toLowerCase().includes(condition.toLowerCase()); 278 | this.log( 279 | node.id, 280 | 'logic_check', 281 | `Condition "${condition}" evaluated as ${match ? 'true' : 'false'}` 282 | ); 283 | const trueConn = this.graph.connections.find( 284 | (c) => c.source === node.id && c.sourceHandle === 'true' 285 | ); 286 | const falseConn = this.graph.connections.find( 287 | (c) => c.source === node.id && c.sourceHandle === 'false' 288 | ); 289 | if (match && trueConn) return trueConn.target; 290 | if (!match && falseConn) return falseConn.target; 291 | return null; 292 | } 293 | 294 | private async executeAgentNode(node: WorkflowNode): Promise { 295 | const previousOutput = this.state.last_output; 296 | let userContent = ''; 297 | 298 | if (node.data?.userPrompt && typeof node.data.userPrompt === 'string' && node.data.userPrompt.trim()) { 299 | userContent = node.data.userPrompt; 300 | } else if (typeof previousOutput === 'string') { 301 | userContent = previousOutput; 302 | } else if (previousOutput !== undefined && previousOutput !== null) { 303 | userContent = JSON.stringify(previousOutput); 304 | } 305 | 306 | if ( 307 | previousOutput && 308 | typeof previousOutput === 'object' && 309 | ('decision' in (previousOutput as Record) || 310 | 'note' in (previousOutput as Record)) 311 | ) { 312 | const safe = this.findLastNonApprovalOutput(); 313 | userContent = safe || ''; 314 | } 315 | 316 | const invocation: AgentInvocation = { 317 | systemPrompt: 318 | (node.data?.systemPrompt as string) || 'You are a helpful assistant.', 319 | userContent, 320 | model: (node.data?.model as string) || 'gpt-5', 321 | reasoningEffort: (node.data?.reasoningEffort as string) || DEFAULT_REASONING, 322 | tools: node.data?.tools as AgentToolsConfig 323 | }; 324 | 325 | this.log(node.id, 'start_prompt', invocation.userContent || ''); 326 | 327 | try { 328 | const responseText = await this.llm.respond(invocation); 329 | this.log(node.id, 'llm_response', responseText); 330 | return responseText; 331 | } catch (error) { 332 | const message = error instanceof Error ? error.message : String(error); 333 | this.log(node.id, 'llm_error', message); 334 | return `LLM error: ${message}`; 335 | } 336 | } 337 | 338 | private findLastNonApprovalOutput(): string | null { 339 | const entries = Object.entries(this.state); 340 | for (let i = entries.length - 1; i >= 0; i -= 1) { 341 | const [key, value] = entries[i]; 342 | if (key.includes('_approval') || key === 'last_output' || key === 'pre_approval_output') { 343 | continue; 344 | } 345 | if (typeof value === 'string') { 346 | return value; 347 | } 348 | } 349 | return null; 350 | } 351 | 352 | private normalizeApprovalInput(input?: ApprovalInput | string | Record): ApprovalInput { 353 | if (typeof input === 'string') { 354 | return { 355 | decision: input.toLowerCase().includes('reject') ? 'reject' : 'approve', 356 | note: '' 357 | }; 358 | } 359 | if (input && typeof input === 'object') { 360 | const decision = 361 | input.decision === 'reject' || 362 | (typeof input.decision === 'string' && input.decision.toLowerCase() === 'reject') 363 | ? 'reject' 364 | : 'approve'; 365 | return { 366 | decision, 367 | note: typeof input.note === 'string' ? input.note : '' 368 | }; 369 | } 370 | return { decision: 'approve', note: '' }; 371 | } 372 | 373 | private describeApprovalResult(result: ApprovalInput): string { 374 | const base = result.decision === 'approve' ? 'User approved this step.' : 'User rejected this step.'; 375 | if (result.note && result.note.trim()) { 376 | return `${base} Feedback: ${result.note.trim()}`; 377 | } 378 | return base; 379 | } 380 | } 381 | 382 | export default WorkflowEngine; 383 | 384 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # CodeSignal Design System - Agent Reference Guide 2 | 3 | This document provides comprehensive information for agentic code generators to effectively use the CodeSignal Design System. 4 | 5 | ## Table of Contents 6 | 7 | 1. [System Overview](#system-overview) 8 | 2. [Installation & Setup](#installation--setup) 9 | 3. [Design Tokens](#design-tokens) 10 | 4. [Components](#components) 11 | 5. [Usage Patterns](#usage-patterns) 12 | 6. [Best Practices](#best-practices) 13 | 7. [File Structure](#file-structure) 14 | 15 | --- 16 | 17 | ## System Overview 18 | 19 | The CodeSignal Design System is a CSS-based design system organized into **Foundations** (design tokens) and **Components** (reusable UI elements). All components are built using CSS custom properties (CSS variables) for theming and consistency. 20 | 21 | ### Key Principles 22 | 23 | - **Semantic over Primitive**: Always prefer semantic tokens (e.g., `--Colors-Text-Body-Default`) over base scale tokens 24 | - **Dark Mode Support**: All components automatically adapt to dark mode via `@media (prefers-color-scheme: dark)` 25 | - **CSS-First**: Components are primarily CSS-based with minimal JavaScript (only Dropdown requires JS) 26 | - **Accessibility**: Components follow WCAG guidelines and support keyboard navigation 27 | 28 | --- 29 | 30 | ## Installation & Setup 31 | 32 | ### Required CSS Files (Load in Order) 33 | 34 | ```html 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | ``` 53 | 54 | ### Alternative: CSS Import 55 | 56 | ```css 57 | @import url('/design-system/colors/colors.css'); 58 | @import url('/design-system/spacing/spacing.css'); 59 | @import url('/design-system/typography/typography.css'); 60 | @import url('/design-system/components/button/button.css'); 61 | ``` 62 | 63 | ### JavaScript (Only for Dropdown) 64 | 65 | ```html 66 | 69 | ``` 70 | 71 | --- 72 | 73 | ## Design Tokens 74 | 75 | ### Colors 76 | 77 | #### Base Scales (Primitive Tokens) 78 | **Avoid using these directly** - use semantic tokens instead for theming support. 79 | 80 | Pattern: `--Colors-Base-[Family]-[Step]` 81 | 82 | **Families:** 83 | - `Primary`: Brand blue colors (20-1400 scale) 84 | - `Neutral`: Grays, white, black (00-1400 scale) 85 | - `Accent-Green`: Success states 86 | - `Accent-Sky-Blue`: Info states 87 | - `Accent-Yellow`: Warning states 88 | - `Accent-Orange`: Warning states 89 | - `Accent-Red`: Error/Danger states 90 | 91 | **Example:** 92 | ```css 93 | --Colors-Base-Primary-700: #1062FB; 94 | --Colors-Base-Neutral-600: #ACB4C7; 95 | --Colors-Base-Accent-Green-600: #10B981; 96 | ``` 97 | 98 | #### Semantic Tokens (Preferred) 99 | **Always use these** for automatic dark mode support and consistency. 100 | 101 | **Categories:** 102 | 103 | 1. **Primary Colors** 104 | - `--Colors-Primary-Default` 105 | - `--Colors-Primary-Medium` 106 | - `--Colors-Primary-Strong` 107 | 108 | 2. **Backgrounds** 109 | - `--Colors-Backgrounds-Main-Default` 110 | - `--Colors-Backgrounds-Main-Top` 111 | - `--Colors-Backgrounds-Main-Medium` 112 | - `--Colors-Backgrounds-Main-Strong` 113 | 114 | 3. **Text Colors** 115 | - `--Colors-Text-Body-Default` 116 | - `--Colors-Text-Body-Secondary` 117 | - `--Colors-Text-Body-Medium` 118 | - `--Colors-Text-Body-Strong` 119 | - `--Colors-Text-Body-Strongest` 120 | 121 | 4. **Icon Colors** 122 | - `--Colors-Icon-Default` 123 | - `--Colors-Icon-Primary` 124 | - `--Colors-Icon-Secondary` 125 | 126 | 5. **Stroke/Border Colors** 127 | - `--Colors-Stroke-Default` 128 | - `--Colors-Stroke-Strong` 129 | - `--Colors-Stroke-Strongest` 130 | 131 | 6. **Alert Colors** 132 | - `--Colors-Alert-Success-Default`, `--Colors-Alert-Success-Medium` 133 | - `--Colors-Alert-Error-Default`, `--Colors-Alert-Error-Medium` 134 | - `--Colors-Alert-Warning-Default`, `--Colors-Alert-Warning-Medium` 135 | - `--Colors-Alert-Info-Default`, `--Colors-Alert-Info-Medium` 136 | 137 | ### Spacing 138 | 139 | Pattern: `--UI-Spacing-spacing-[size]` 140 | 141 | **Available Sizes:** 142 | - `none`: 0 143 | - `min`: 2px 144 | - `xxs`: 4px 145 | - `xs`: 6px 146 | - `s`: 8px 147 | - `mxs`: 12px 148 | - `ms`: 16px 149 | - `m`: 18px 150 | - `ml`: 20px 151 | - `mxl`: 24px 152 | - `l`: 28px 153 | - `xl`: 32px 154 | - `xxl`: 36px 155 | - `xxxl`: 48px 156 | - `4xl`: 60px 157 | - `max`: 90px 158 | 159 | **Usage:** 160 | ```css 161 | padding: var(--UI-Spacing-spacing-m); 162 | margin: var(--UI-Spacing-spacing-s); 163 | gap: var(--UI-Spacing-spacing-mxl); 164 | ``` 165 | 166 | ### Border Radius 167 | 168 | Pattern: `--UI-Radius-radius-[size]` 169 | 170 | **Available Sizes:** 171 | - `none`: 0 172 | - `min`: 2px 173 | - `xxs`: 4px 174 | - `xs`: 6px 175 | - `s`: 8px 176 | - `m`: 12px 177 | - `ml`: 16px 178 | - `mxl`: 20px 179 | - `l`: 24px 180 | - `xl`: 32px 181 | 182 | **Usage:** 183 | ```css 184 | border-radius: var(--UI-Radius-radius-m); 185 | ``` 186 | 187 | ### Input Heights 188 | 189 | Pattern: `--UI-Input-[size]` 190 | 191 | **Available Sizes:** 192 | - `min`: 26px 193 | - `xs`: 32px 194 | - `sm`: 40px 195 | - `md`: 48px (default) 196 | - `lg`: 60px 197 | 198 | **Usage:** 199 | ```css 200 | height: var(--UI-Input-md); 201 | ``` 202 | 203 | ### Typography 204 | 205 | #### Font Families 206 | - **Body & Labels**: `Work Sans` (sans-serif) - Must be loaded from Google Fonts 207 | - **Headings**: `Founders Grotesk` (sans-serif) - Included via `@font-face` 208 | - **Code**: `JetBrains Mono` (monospace) - Included via `@font-face` 209 | 210 | #### Typography Classes 211 | 212 | **Body Text** (Work Sans): 213 | - `.body-xxsmall` (13px) 214 | - `.body-xsmall` (14px) 215 | - `.body-small` (15px) 216 | - `.body-medium` (16px) 217 | - `.body-large` (17px) 218 | - `.body-xlarge` (19px) 219 | - `.body-xxlarge` (21px) 220 | - `.body-xxxlarge` (24px) 221 | 222 | **Body Elegant** (Founders Grotesk): 223 | - `.body-elegant-xxsmall` (22px) 224 | - `.body-elegant-xsmall` (26px) 225 | - `.body-elegant-small` (32px) 226 | - `.body-elegant-medium` (38px) 227 | 228 | **Headings** (Founders Grotesk, 500 weight): 229 | - `.heading-xxxsmall` (16px) 230 | - `.heading-xxsmall` (22px) 231 | - `.heading-xsmall` (22px) 232 | - `.heading-small` (24px) 233 | - `.heading-medium` (32px) 234 | - `.heading-large` (38px) 235 | - `.heading-xlarge` (48px) 236 | - `.heading-xxlarge` (64px) 237 | 238 | **Labels** (Work Sans, 600 weight, uppercase): 239 | - `.label-small` (10px) 240 | - `.label-medium` (11px) 241 | - `.label-large` (14px) 242 | 243 | **Label Numbers** (Work Sans, 500 weight): 244 | - `.label-number-xsmall` (11px) 245 | - `.label-number-small` (12px) 246 | - `.label-number-medium` (14px) 247 | - `.label-number-large` (15px) 248 | 249 | --- 250 | 251 | ## Components 252 | 253 | ### Button 254 | 255 | **Base Class:** `.button` (required) 256 | 257 | **Variants:** 258 | - `.button-primary`: Primary action (Brand Blue background) 259 | - `.button-secondary`: Secondary action (Outlined style) 260 | - `.button-tertiary`: Tertiary/Ghost (Subtle background) 261 | - `.button-danger`: Destructive action (Red) 262 | - `.button-success`: Positive action (Green) 263 | - `.button-text`: Text button (Neutral text, no background) 264 | - `.button-text-primary`: Primary text button (Brand color text) 265 | 266 | **Sizes:** 267 | - `.button-xsmall`: 32px height 268 | - `.button-small`: 40px height 269 | - Default: 48px height (medium) 270 | - `.button-large`: 60px height 271 | 272 | **States:** 273 | - Standard pseudo-classes: `:hover`, `:focus`, `:active`, `:disabled` 274 | - Utility classes: `.hover`, `.focus`, `.active`, `.disabled` 275 | 276 | **Example:** 277 | ```html 278 | 279 | 280 | 281 | ``` 282 | 283 | **Dependencies:** colors.css, spacing.css, typography.css 284 | 285 | --- 286 | 287 | ### Box 288 | 289 | **Base Class:** `.box` (required) 290 | 291 | **Variants:** 292 | - `.box.selected`: Selected state (Primary border) 293 | - `.box.emphasized`: Emphasized state (Neutral border) 294 | - `.box.shadowed`: Soft shadow 295 | - `.box.card`: Card-style shadow 296 | 297 | **States:** 298 | - Standard pseudo-classes: `:hover`, `:focus`, `:active` 299 | - Utility classes: `.hover`, `.focus`, `.selected` 300 | 301 | **Example:** 302 | ```html 303 |
Default content
304 |
Selected content
305 |
Card content
306 | ``` 307 | 308 | **Dependencies:** colors.css, spacing.css 309 | 310 | --- 311 | 312 | ### Input 313 | 314 | **Base Class:** `.input` (required) 315 | 316 | **Input Types:** 317 | - `type="text"`: Standard text input (default) 318 | - `type="number"`: Numeric input with styled spinner buttons 319 | 320 | **States:** 321 | - Standard pseudo-classes: `:hover`, `:focus`, `:disabled` 322 | - Utility classes: `.hover`, `.focus` 323 | 324 | **Features:** 325 | - Automatic focus ring (primary color with reduced opacity) 326 | - Styled number input spinners 327 | - Dark mode support 328 | 329 | **Example:** 330 | ```html 331 | 332 | 333 | 334 | ``` 335 | 336 | **Dependencies:** colors.css, spacing.css, typography.css 337 | 338 | --- 339 | 340 | ### Tag 341 | 342 | **Base Class:** `.tag` or `.tag.default` (required) 343 | 344 | **Variants:** 345 | - `.tag` / `.tag.default`: Primary tag (Brand Blue background) 346 | - `.tag.secondary`: Secondary tag (Neutral gray background) 347 | - `.tag.outline`: Outline tag (Transparent with border) 348 | - `.tag.success`: Success tag (Green background) 349 | - `.tag.error`: Error tag (Red background) 350 | - `.tag.warning`: Warning tag (Yellow background) 351 | - `.tag.info`: Info tag (Sky Blue background) 352 | 353 | **States:** 354 | - Standard pseudo-classes: `:hover`, `:focus`, `:active` 355 | - Utility classes: `.hover`, `.focus`, `.active` 356 | 357 | **Example:** 358 | ```html 359 |
Default
360 |
Completed
361 |
Failed
362 |
Filter
363 | ``` 364 | 365 | **Dependencies:** colors.css, spacing.css, typography.css 366 | 367 | --- 368 | 369 | ### Icon 370 | 371 | **Base Class:** `.icon` (required) 372 | 373 | **Icon Names:** 374 | Use `.icon-[name]` where `[name]` is derived from SVG filename (e.g., `Icon=Academy.svg` → `.icon-academy`) 375 | 376 | **Available Icons** (80+ icons): 377 | - `.icon-academy` 378 | - `.icon-assessment` 379 | - `.icon-interview` 380 | - `.icon-jobs` 381 | - `.icon-course` 382 | - ... (see `icons.css` for full list) 383 | 384 | **Sizes:** 385 | - `.icon-small`: 16px 386 | - `.icon-medium`: 24px (default) 387 | - `.icon-large`: 32px 388 | - `.icon-xlarge`: 48px 389 | 390 | **Colors:** 391 | - Default: Uses `currentColor` (inherits text color) 392 | - `.icon-primary`: Primary brand color 393 | - `.icon-secondary`: Secondary neutral color 394 | - `.icon-success`: Success green color 395 | - `.icon-danger`: Danger red color 396 | - `.icon-warning`: Warning yellow color 397 | 398 | **Implementation Note:** 399 | Icons use `mask-image` with `background-color` for color control. SVGs in data URIs use black fills (black = visible in mask). 400 | 401 | **Example:** 402 | ```html 403 | 404 | 405 | 406 | ``` 407 | 408 | **Dependencies:** colors.css, spacing.css 409 | 410 | --- 411 | 412 | ### Dropdown (JavaScript Component) 413 | 414 | **Import:** 415 | ```javascript 416 | import Dropdown from '/design-system/components/dropdown/dropdown.js'; 417 | ``` 418 | 419 | **Initialization:** 420 | ```javascript 421 | const dropdown = new Dropdown(selector, options); 422 | ``` 423 | 424 | **Configuration Options:** 425 | 426 | | Option | Type | Default | Description | 427 | |--------|------|---------|-------------| 428 | | `items` | Array | `[]` | Array of `{value, label}` objects | 429 | | `placeholder` | String | `'Select option'` | Placeholder text | 430 | | `selectedValue` | String | `null` | Initial selected value | 431 | | `width` | String/Number | `'auto'` | Fixed width (ignored if `growToFit` is true) | 432 | | `growToFit` | Boolean | `false` | Auto-resize to fit content | 433 | | `onSelect` | Function | `null` | Callback `(value, item)` on selection | 434 | 435 | **API Methods:** 436 | - `getValue()`: Returns current selected value 437 | - `setValue(value)`: Sets selected value programmatically 438 | - `open()`: Opens dropdown menu 439 | - `close()`: Closes dropdown menu 440 | - `toggleOpen()`: Toggles open state 441 | - `destroy()`: Removes event listeners and clears container 442 | 443 | **Example:** 444 | ```javascript 445 | const dropdown = new Dropdown('#my-dropdown', { 446 | placeholder: 'Choose an option', 447 | items: [ 448 | { value: '1', label: 'Option 1' }, 449 | { value: '2', label: 'Option 2' }, 450 | { value: '3', label: 'Option 3' } 451 | ], 452 | onSelect: (value, item) => { 453 | console.log('Selected:', value, item); 454 | } 455 | }); 456 | 457 | // Later... 458 | dropdown.setValue('2'); 459 | const currentValue = dropdown.getValue(); 460 | ``` 461 | 462 | **Dependencies:** colors.css, spacing.css, typography.css 463 | 464 | --- 465 | 466 | ## Usage Patterns 467 | 468 | ### Component Composition 469 | 470 | Components can be combined and nested: 471 | 472 | ```html 473 | 474 | 478 | 479 | 480 |
481 | 482 | Completed 483 |
484 | 485 | 486 |
487 | 488 | 489 |
490 | ``` 491 | 492 | ### Custom Styling 493 | 494 | You can extend components using CSS custom properties: 495 | 496 | ```css 497 | .my-custom-button { 498 | /* Inherit button styles */ 499 | composes: button button-primary; 500 | 501 | /* Override with custom properties */ 502 | --Colors-Base-Primary-700: #custom-color; 503 | } 504 | ``` 505 | 506 | ### Responsive Design 507 | 508 | Use standard CSS media queries with design tokens: 509 | 510 | ```css 511 | @media (max-width: 768px) { 512 | .responsive-box { 513 | padding: var(--UI-Spacing-spacing-s); 514 | } 515 | } 516 | ``` 517 | 518 | --- 519 | 520 | ## Best Practices 521 | 522 | ### 1. Token Usage 523 | 524 | ✅ **DO:** 525 | ```css 526 | color: var(--Colors-Text-Body-Default); 527 | padding: var(--UI-Spacing-spacing-m); 528 | border-radius: var(--UI-Radius-radius-m); 529 | ``` 530 | 531 | ❌ **DON'T:** 532 | ```css 533 | color: #333; 534 | padding: 16px; 535 | border-radius: 8px; 536 | ``` 537 | 538 | ### 2. Component Classes 539 | 540 | ✅ **DO:** 541 | ```html 542 | 543 | ``` 544 | 545 | ❌ **DON'T:** 546 | ```html 547 | 548 | ``` 549 | 550 | ### 3. Dark Mode 551 | 552 | ✅ **DO:** Use semantic tokens (automatic dark mode) 553 | ```css 554 | background: var(--Colors-Backgrounds-Main-Default); 555 | ``` 556 | 557 | ❌ **DON'T:** Use hardcoded colors 558 | ```css 559 | background: #ffffff; 560 | ``` 561 | 562 | ### 4. File Loading Order 563 | 564 | ✅ **DO:** Load foundations before components 565 | ```html 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | ``` 574 | 575 | ### 5. Icon Usage 576 | 577 | ✅ **DO:** 578 | ```html 579 | 580 | ``` 581 | 582 | ❌ **DON'T:** Use inline SVG or img tags for icons 583 | ```html 584 | icon 585 | ``` 586 | 587 | ### 6. Accessibility 588 | 589 | - Always include proper `alt` text for images 590 | - Use semantic HTML (`