WorkRunner Documentation
Step 2of 2

Build Your First Construct

Let's build a simple todo list to learn the Construct patterns.

1. Initialize the Project

Use the SDK to create a new Construct:

$ npx @useworkapp/construct-sdk init "Todo List"
$ cd todo-list
$ npm install
$ npm run dev

This creates a complete Vite + React + Tailwind project with shadcn/ui components pre-installed. Open http://localhost:5173 to see the starter app.

2. Build Your App

Open src/App.tsx and replace it with:

Imports

"use client";

import { useState } from "react";
import { useConstructData } from "@useworkapp/construct-sdk";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Plus, Trash2 } from "lucide-react";

Use useConstructData from the SDK for persistence. Import UI components from shadcn/ui and icons from lucide-react.

Types

interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

State with useConstructData

export default function App() {
  const [todos, setTodos, { isLoading }] = useConstructData<Todo[]>("todos", []);
  const [input, setInput] = useState("");

  if (isLoading) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        Loading...
      </div>
    );
  }

useConstructData handles IndexedDB storage automatically. Always handle the loading state before rendering data-dependent UI.

Actions

const addTodo = () => {
  if (!input.trim()) return;
  setTodos([
    ...todos,
    { id: crypto.randomUUID(), text: input.trim(), completed: false }
  ]);
  setInput("");
};

const toggleTodo = (id: string) => {
  setTodos(todos.map(t =>
    t.id === id ? { ...t, completed: !t.completed } : t
  ));
};

const deleteTodo = (id: string) => {
  setTodos(todos.filter(t => t.id !== id));
};

JSX

return (
  <div className="min-h-screen bg-background p-4 sm:p-6">
    <Card className="mx-auto max-w-md">
      <CardHeader>
        <CardTitle>Todo List</CardTitle>
      </CardHeader>
      <CardContent className="space-y-4">
        {/* Add form */}
        <form
          onSubmit={(e) => { e.preventDefault(); addTodo(); }}
          className="flex gap-2"
        >
          <Input
            value={input}
            onChange={(e) => setInput(e.target.value)}
            placeholder="Add a task..."
          />
          <Button type="submit" size="icon" aria-label="Add task">
            <Plus className="h-4 w-4" />
          </Button>
        </form>

        {/* Todo list */}
        <div className="space-y-2">
          {todos.map((todo) => (
            <div
              key={todo.id}
              className="flex items-center gap-3 p-2 border rounded"
            >
              <Checkbox
                id={todo.id}
                checked={todo.completed}
                onCheckedChange={() => toggleTodo(todo.id)}
              />
              <Label
                htmlFor={todo.id}
                className={`flex-1 ${todo.completed ? "line-through text-muted-foreground" : ""}`}
              >
                {todo.text}
              </Label>
              <Button
                variant="ghost"
                size="icon"
                onClick={() => deleteTodo(todo.id)}
                aria-label="Delete task"
              >
                <Trash2 className="h-4 w-4" />
              </Button>
            </div>
          ))}
        </div>

        {todos.length === 0 && (
          <p className="text-center text-muted-foreground text-sm">
            No tasks yet. Add one above!
          </p>
        )}
      </CardContent>
    </Card>
  </div>
);

3. Validate

Run the validator to ensure your Construct meets all requirements:

$ npx @useworkapp/construct-sdk validate

Validating construct...
✓ App.tsx exists
✓ Default export found
✓ Using allowed imports only
✓ Data persistence with useConstructData
✓ Accessibility: aria-label on icon buttons
✓ Accessibility: Labels connected to inputs

✓ Construct passes all validations!

4. Publish & Submit

Once validated, publish and submit for review:

# Login (first time only)
$ npx @useworkapp/construct-sdk login

# Publish to Work platform
$ npx @useworkapp/construct-sdk publish
✓ Construct published! ID: abc123

# Submit for review
$ npx @useworkapp/construct-sdk submit abc123
✓ Submitted for review. We'll notify you within 48 hours.

Key Patterns to Remember

Use useConstructData for persistence

Handles IndexedDB, loading states, and optional cloud sync automatically.

Handle loading states

Always check isLoading before rendering data-dependent UI.

Add accessibility attributes

Icon buttons need aria-label. Connect labels to inputs with id/htmlFor.

Use Tailwind for styling

Use utility classes, not inline styles. Include responsive padding.

Common Mistakes to Avoid

Don't use fetch or axios

Constructs run in a sandbox without network access. Use IndexedDB for storage.

Don't skip the loading state

Data loads async from IndexedDB. Show a loading indicator while isLoading is true.

Don't use inline styles

Use Tailwind classes instead of style={{}}.

Don't forget accessibility

Icon buttons need aria-label. Form inputs need associated labels.

Next: Explore Constructs

Learn about components, data patterns, and more.

Your First Construct | Runner Documentation | Work