Build a Full-Stack Todo App with AI#
This tutorial walks you through building a complete full-stack application with Jac, covering server-side graph operations, client-side React UI, and AI-powered features using by llm().
Prerequisites: Complete Project Setup first.
Reference: Walker Responses | Graph Operations
What You'll Learn#
- Server/Client separation with
sv {}andcl {}blocks - Graph operations - creating, traversing, and deleting nodes
- Walker patterns - CRUD operations and data accumulation
- Walker response handling - understanding
.reportsstructure - Client-server communication - spawning walkers from the frontend
- AI integration - using
by llm()with semantic hints
Project Structure#
todo-app/
├── main.jac # Entry point
├── endpoints.sv.jac # Server-side walkers and nodes
├── frontend.cl.jac # Client-side UI
└── components/
├── AuthForm.cl.jac # Login/signup form
└── TodoItem.cl.jac # Todo item component
Part 1: Server-Side Data Layer#
1.1 Define the Data Model#
Create endpoints.sv.jac - this file contains server-side code that persists data in the graph.
"""Todo App - Server-Side Data Layer."""
import from uuid { uuid4 }
# Define a node to store todo items
node Todo {
has id: str,
title: str,
completed: bool = False,
priority: str = "medium",
parent_id: str = "";
}
Key concepts:
nodedefines a persistent data type stored in the graphhasdeclares node properties with optional default values- Nodes are connected to the user's root node for per-user isolation
1.2 Create Walker - Adding Todos#
Walkers traverse the graph and perform operations. Here's a walker to add new todos:
walker:priv AddTodo {
has title: str,
priority: str = "medium",
parent_id: str = "";
can create with `root entry {
# Generate a unique ID
new_id = str(uuid4());
# Create a new Todo node and connect it to root
new_todo = here ++> Todo(
id=new_id,
title=self.title,
completed=False,
priority=self.priority,
parent_id=self.parent_id
);
# Report the created todo back to the caller
report new_todo[0];
}
}
Key concepts:
walker:priv- Private walker (not exposed as REST API, called from code)has- Walker parameters passed during instantiationcan create withroot entry` - Ability that runs when walker enters the root nodehere ++> Node(...)- Creates a new node and connects it to the current node (here)new_todo[0]- The++>operator returns a list; access the first elementreport- Returns data to the caller (collected in.reportsarray)
1.3 List Walker - Accumulating Data Across Traversal#
This walker demonstrates the accumulator pattern - collecting data as it traverses the graph:
walker:priv ListTodos {
has todos: list = [];
# Entry point: start traversing from root
can collect with `root entry {
visit [-->]; # Visit all outgoing edges
}
# Called for each Todo node encountered
can gather with Todo entry {
self.todos.append({
"id": here.id,
"title": here.title,
"completed": here.completed,
"priority": here.priority,
"parent_id": here.parent_id
});
}
# Exit point: report accumulated data
can report_all with `root exit {
report self.todos;
}
}
Key concepts:
- Multiple
canabilities with different triggers withroot entry` - Runs when entering the root nodewith Todo entry- Runs when entering any Todo nodewithroot exit` - Runs when exiting the root node (after traversal)visit [-->]- Traverse all outgoing edges from current nodeself.todos- Walker state persists across the traversal- The pattern: enter root → visit children → gather from each → exit root with results
1.4 Toggle and Delete Walkers#
walker:priv ToggleTodo {
has todo_id: str;
can search with `root entry {
visit [-->];
}
can toggle with Todo entry {
if here.id == self.todo_id {
here.completed = not here.completed;
report {"id": here.id, "completed": here.completed};
}
}
}
walker:priv DeleteTodo {
has todo_id: str;
can search with `root entry {
visit [-->];
}
can delete with Todo entry {
# Delete the todo and its children (cascade)
if here.id == self.todo_id or here.parent_id == self.todo_id {
del here;
report {"deleted": self.todo_id};
}
}
}
Key concepts:
here.property = value- Modify node properties directlydel here- Remove the current node from the graph- Conditional logic to find the target node during traversal
Part 2: Understanding Walker Responses#
This is a critical pattern that trips up many developers.
2.1 The .reports Array#
When you spawn a walker, every report statement adds to a .reports array:
# Walker with multiple reports
walker:priv MyWalker {
can do_stuff with `root entry {
report "first"; # reports[0]
report "second"; # reports[1]
report "third"; # reports[2]
}
}
2.2 Reports During Traversal#
When a walker visits multiple nodes and reports from each:
walker:priv VisitAll {
can start with `root entry {
visit [-->];
}
can process with Todo entry {
report here.title; # Reports once per Todo node visited
}
}
# If there are 3 todos, response.reports = ["todo1", "todo2", "todo3"]
2.3 Common Pattern: Single Accumulated Report#
The ListTodos walker uses the cleanest pattern - accumulate internally, report once:
walker:priv ListTodos {
has todos: list = [];
can collect with `root entry { visit [-->]; }
can gather with Todo entry { self.todos.append({...}); }
can report_all with `root exit { report self.todos; } # Single report
}
# response.reports[0] = [all todos as a list]
2.4 Handling Responses in Client Code#
cl {
# Safe pattern for single-report walkers
async def example() -> None {
result = root spawn ListTodos();
todos = result.reports[0] if result.reports else [];
}
}
Part 3: Client-Side UI#
3.1 Entry Point (main.jac)#
"""Todo App - Entry Point."""
# Server-side imports
sv {
import from endpoints { Todo, AddTodo, ListTodos, ToggleTodo, DeleteTodo }
}
# Client-side UI
cl {
import from frontend { app as ClientApp }
def:pub app -> any {
return <ClientApp />;
}
}
Key concepts:
sv { }block - Server-side code, runs on the backendcl { }block - Client-side code, runs in the browserdef:pub- Public function exported as the app entry point
3.2 Frontend Component (frontend.cl.jac)#
"""Todo App - Client-Side UI."""
import from react { useEffect }
import from "@jac-client/utils" { jacSignup, jacLogin, jacLogout, jacIsLoggedIn }
# Import server-side walkers for client use
sv import from endpoints { AddTodo, ListTodos, ToggleTodo, DeleteTodo }
def:pub app -> any {
# Component state
has isLoggedIn: bool = False,
todos: list = [],
newTodoText: str = "",
todosLoading: bool = True;
# Check auth on mount
useEffect(lambda -> None {
isLoggedIn = jacIsLoggedIn();
}, []);
# Fetch todos when logged in
useEffect(
lambda -> None { if isLoggedIn { fetchTodos(); }},
[isLoggedIn]
);
# Fetch all todos from server
async def fetchTodos -> None {
todosLoading = True;
result = root spawn ListTodos();
todos = result.reports[0] if result.reports else [];
todosLoading = False;
}
# Add a new todo
async def addTodo -> None {
if not newTodoText.trim() { return; }
response = root spawn AddTodo(title=newTodoText);
newTodo = response.reports[0];
# Update local state with the new todo
todos = todos.concat([{
"id": newTodo.id,
"title": newTodo.title,
"completed": newTodo.completed,
"priority": newTodo.priority,
"parent_id": newTodo.parent_id
}]);
newTodoText = "";
}
# Toggle todo completion
async def toggleTodo(todoId: str) -> None {
root spawn ToggleTodo(todo_id=todoId);
# Update local state
todos = todos.map(lambda t: any -> any {
if t.id == todoId {
return {
"id": t.id,
"title": t.title,
"completed": not t.completed,
"priority": t.priority,
"parent_id": t.parent_id
};
}
return t;
});
}
# Delete a todo
async def deleteTodo(todoId: str) -> None {
root spawn DeleteTodo(todo_id=todoId);
todos = todos.filter(lambda t: any -> bool {
return t.id != todoId and t.parent_id != todoId;
});
}
# Render UI
return
<div style={{"padding": "2rem", "maxWidth": "600px", "margin": "0 auto"}}>
<h1>My Todos</h1>
<div style={{"display": "flex", "gap": "0.5rem", "marginBottom": "1rem"}}>
<input
type="text"
value={newTodoText}
onChange={lambda e: any -> None { newTodoText = e.target.value; }}
placeholder="What needs to be done?"
style={{"flex": "1", "padding": "0.5rem"}}
/>
<button onClick={lambda -> None { addTodo(); }}>Add</button>
</div>
{(
<p>Loading...</p>
) if todosLoading else (
<ul>
{todos.map(lambda todo: any -> any {
return
<li key={todo.id} style={{"display": "flex", "alignItems": "center", "gap": "0.5rem"}}>
<input
type="checkbox"
checked={todo.completed}
onChange={lambda -> None { toggleTodo(todo.id); }}
/>
<span style={{"textDecoration": (todo.completed if "line-through" else "none")}}>
{todo.title}
</span>
<button onClick={lambda -> None { deleteTodo(todo.id); }}>Delete</button>
</li>;
})}
</ul>
)}
</div>;
}
Key concepts:
sv import from endpoints { ... }- Import server walkers for client useroot spawn WalkerName(params)- Execute a server walker from client codehasin a component - Declares reactive stateuseEffect- React hook for side effectsasync def- Asynchronous function for API calls- JSX syntax with Jac expressions in
{}
Part 4: Adding AI Features#
4.1 Define Structured Types with Semantic Hints#
Add to endpoints.sv.jac:
import from byllm.lib { Model }
# Initialize the LLM model globally
glob llm = Model(model_name="claude-sonnet-4-20250514");
# Enum for units of measurement
enum Unit { PIECE, LB, OZ, CUP, TBSP, TSP, CLOVE, BUNCH }
# Structured object for ingredients
obj Ingredient {
has name: str;
has quantity: float;
has unit: Unit;
has cost: float;
has carby: bool;
}
# Semantic hints guide the LLM's output
sem Ingredient.cost = "Estimated cost in USD";
sem Ingredient.carby = "True if this ingredient will spike blood glucose";
"""
Generate a shopping list of ingredients needed for a described meal.
"""
def generate_ingredients(meal_description: str) -> list[Ingredient] by llm();
Key concepts:
glob llm = Model(...)- Initialize LLM once, globally availableenum- Constrained set of valuesobj- Structured data type (not persisted likenode)sem Field.name = "description"- Semantic hint for LLM guidancedef func() -> Type by llm()- LLM-powered function with structured output- The docstring provides context for the LLM
4.2 Walker That Uses AI#
walker:priv MealToIngredients {
has meal_description: str;
can process with `root entry {
# Call the LLM-powered function
ingredients = generate_ingredients(self.meal_description);
total_cost: float = 0.0;
# Create a todo for each ingredient
for ingredient in ingredients {
new_id = str(uuid4());
title = f"{ingredient.quantity} {ingredient.unit.name} {ingredient.name} (${ingredient.cost:.2f})";
here ++> Todo(
id=new_id,
title=title,
completed=False,
priority="medium",
parent_id=""
);
total_cost += ingredient.cost;
}
# Get the updated todo list
todos_result = root spawn ListTodos();
todos_list = todos_result.reports[0] if todos_result.reports else [];
# Report summary (this becomes reports[1] since ListTodos added reports[0])
report {
"meal": self.meal_description,
"ingredients_added": todos_list,
"total_cost": total_cost
};
}
}
Key concepts:
- Call LLM function:
ingredients = generate_ingredients(self.meal_description) - LLM returns structured
list[Ingredient]automatically parsed - F-strings:
f"{ingredient.quantity} {ingredient.unit.name}" - Nested walker spawn:
root spawn ListTodos()within another walker - Multiple reports:
ListTodosreports first, then this walker reports second
4.3 Client-Side AI Integration#
Add to frontend.cl.jac:
sv import from endpoints { AddTodo, ListTodos, ToggleTodo, DeleteTodo, MealToIngredients }
def:pub app -> any {
has mealDescription: str = "",
mealLoading: bool = False;
# ... other state ...
async def generateMealIngredients -> None {
if not mealDescription.trim() { return; }
mealLoading = True;
try {
response = root spawn MealToIngredients(meal_description=mealDescription);
# MealToIngredients has multiple reports:
# reports[0] = ListTodos output (from nested spawn)
# reports[1] = Summary object with ingredients_added
if response.reports and response.reports.length > 1 {
result = response.reports[1];
added = result["ingredients_added"];
if added and added.length > 0 {
todos = todos.concat(added);
}
}
} except Exception as e {
console.error("Error generating ingredients:", e);
}
mealDescription = "";
mealLoading = False;
}
# In render, add meal input:
return
<div>
<div style={{"marginTop": "2rem", "padding": "1rem", "border": "1px solid #ccc"}}>
<h3>AI Meal Planner</h3>
<input
type="text"
value={mealDescription}
onChange={lambda e: any -> None { mealDescription = e.target.value; }}
placeholder="Describe a meal (e.g., spaghetti bolognese)"
disabled={mealLoading}
/>
<button
onClick={lambda -> None { generateMealIngredients(); }}
disabled={mealLoading}
>
{("Generating..." if mealLoading else "Generate Shopping List")}
</button>
</div>
</div>;
}
Part 5: Running the App#
5.1 Start the Development Server#
# Set your API key for AI features
export ANTHROPIC_API_KEY="your-key-here"
# Start the server
jac start main.jac --port 8000
5.2 Access the App#
Open http://localhost:8000 in your browser.
5.3 Test the Features#
- Register/Login - Create an account
- Add Todos - Type and click Add
- Toggle Complete - Click the checkbox
- Delete - Click Delete button
- AI Generate - Type "tacos" and click Generate Shopping List
Summary: Key Patterns#
Graph Operations#
| Operation | Syntax | Description |
|---|---|---|
| Create & Connect | here ++> Node(...) |
Creates node, connects to current |
| Traverse | visit [-->] |
Visit all outgoing edges |
| Delete | del here |
Remove current node |
| Access | here.property |
Read/write node properties |
Walker Response Pattern#
# Walker
walker MyWalker {
can do_work with `root entry {
report "data"; # Adds to .reports array
}
}
Client-Server Communication#
# Import server walkers in client code
sv import from endpoints { MyWalker }
# Spawn walker from client
async def callServer -> None {
result = root spawn MyWalker(param="value");
data = result.reports[0] if result.reports else [];
}
AI Integration#
# Define structured type with semantic hints
obj MyType {
has field: str;
}
sem MyType.field = "Description for LLM";
# LLM-powered function
def myFunc(input: str) -> MyType by llm();
Next Steps#
Extend this app:
- Add sub-todos with parent-child relationships
- Implement priority filtering
- Add due dates with calendar integration
Learn more:
- Walker Responses - Deep dive into
.reportspatterns - Graph Operations - Complete reference for graph operators
- byLLM Reference - Full AI integration documentation
- Deploy to Kubernetes - Production deployment