Legal Case Tracker
Agent-ready recipe for creating a legal case tracker in andibase
This recipe tells an agent exactly how to build a legal case tracker in andibase.
Goal
Create four things in one workspace:
- A
legal-casedata definition. - A
legal-case-commentdata definition. - One agent that helps users manage case records and updates.
- One mini app that shows case details, comments, and lets members update status.
Expected outcome
After following this recipe, the workspace should have:
- a
legal-casedefinition for matters and their current state - a
legal-case-commentdefinition for case notes and updates - a
legal-case-agentagent - a
legal-case-trackermini app
Use the API documented in:
- Data Model
- Apps
- Get started (for AI Agents)
- Data definitions API
- Data API
- Agents API
- Apps API reference
Important scope note
This recipe creates an operational tracker for legal matters.
It does not make legal decisions, give legal advice, or replace attorney review.
Agent instructions
When an agent executes this recipe, it should follow these rules:
- Create the
legal-casedefinition first. - Create the
legal-case-commentdefinition second. - In the current API, data definition handles are generated from
name. Usename: "Legal Case"andname: "Legal Case Comment"so the created handles normalize tolegal-caseandlegal-case-comment. - If your execution environment can resolve the created legal-case definition id, wire the comment-to-case link as a
relationshipfield. If it cannot, use a text foreign key such ascaseRecordIdinstead, because the public HTTP API still expectsdataDefinitionIdfor relationship fields. - Create the legal-case agent with instructions focused on case intake, status updates, and timeline hygiene.
- Create the mini app last.
- Do not invent additional legal workflows, billing logic, or compliance fields unless the user explicitly asks for them.
- Use sensible defaults and ask the user only when a missing choice materially changes the workflow, such as jurisdiction, visibility, or status policy.
Step 1: Create the data model
Create two data definitions: legal-case and legal-case-comment.
1. Legal Case definition
Use name: "Legal Case". The current API derives the handle from the name, so this will create the legal-case handle.
Recommended fields:
{
"name": "Legal Case",
"description": "Tracks one legal matter, its current status, and operational details.",
"fields": {
"title": {
"name": "Title",
"type": "text"
},
"caseNumber": {
"name": "Case number",
"type": "text"
},
"clientName": {
"name": "Client name",
"type": "text"
},
"matterType": {
"name": "Matter type",
"type": "select",
"options": [
{ "value": "litigation", "label": "Litigation", "color": "red" },
{ "value": "contract", "label": "Contract", "color": "blue" },
{ "value": "employment", "label": "Employment", "color": "amber" },
{ "value": "regulatory", "label": "Regulatory", "color": "violet" },
{ "value": "ip", "label": "IP", "color": "cyan" },
{ "value": "corporate", "label": "Corporate", "color": "emerald" },
{ "value": "other", "label": "Other", "color": "zinc" }
]
},
"jurisdiction": {
"name": "Jurisdiction",
"type": "text"
},
"courtOrForum": {
"name": "Court or forum",
"type": "text"
},
"assignedOwner": {
"name": "Assigned owner",
"type": "text"
},
"priority": {
"name": "Priority",
"type": "select",
"options": [
{ "value": "low", "label": "Low", "color": "zinc" },
{ "value": "medium", "label": "Medium", "color": "blue" },
{ "value": "high", "label": "High", "color": "amber" },
{ "value": "critical", "label": "Critical", "color": "red" }
]
},
"status": {
"name": "Status",
"type": "select",
"options": [
{ "value": "intake", "label": "Intake", "color": "sky" },
{ "value": "active", "label": "Active", "color": "green" },
{ "value": "waiting-on-client", "label": "Waiting on client", "color": "amber" },
{ "value": "waiting-on-court", "label": "Waiting on court", "color": "violet" },
{ "value": "review", "label": "Review", "color": "blue" },
{ "value": "closed", "label": "Closed", "color": "stone" },
{ "value": "archived", "label": "Archived", "color": "zinc" }
]
},
"openedOn": {
"name": "Opened on",
"type": "date"
},
"nextDeadline": {
"name": "Next deadline",
"type": "date"
},
"summary": {
"name": "Summary",
"type": "text",
"variant": "long-text"
},
"details": {
"name": "Details",
"type": "text",
"variant": "long-text"
},
"lastUpdateSummary": {
"name": "Last update summary",
"type": "text",
"variant": "long-text"
},
"commentsCount": {
"name": "Comments count",
"type": "number"
},
"lastCommentAt": {
"name": "Last comment at",
"type": "timestamp"
},
"tags": {
"name": "Tags",
"type": "multi-select",
"options": [
{ "value": "dispute", "label": "Dispute", "color": "red" },
{ "value": "urgent", "label": "Urgent", "color": "amber" },
{ "value": "client-facing", "label": "Client-facing", "color": "blue" },
{ "value": "internal", "label": "Internal", "color": "zinc" }
]
}
}
}Recommended defaults for agent-created case rows:
priority:mediumstatus:intakecommentsCount:0openedOn: today
2. Legal Case Comment definition
Use name: "Legal Case Comment". The current API derives the handle from the name, so this will create the legal-case-comment handle.
Recommended fields:
{
"name": "Legal Case Comment",
"description": "Tracks timeline comments, notes, and case updates.",
"fields": {
"caseRecordId": {
"name": "Case record id",
"description": "Store the linked legal-case row id when the client only has public HTTP API access.",
"type": "text"
},
"authorName": {
"name": "Author name",
"type": "text"
},
"commentType": {
"name": "Comment type",
"type": "select",
"options": [
{ "value": "note", "label": "Note", "color": "zinc" },
{ "value": "status-update", "label": "Status update", "color": "blue" },
{ "value": "deadline", "label": "Deadline", "color": "amber" },
{ "value": "client-update", "label": "Client update", "color": "cyan" },
{ "value": "court-update", "label": "Court update", "color": "violet" }
]
},
"visibility": {
"name": "Visibility",
"type": "select",
"options": [
{ "value": "internal", "label": "Internal", "color": "zinc" },
{ "value": "shareable", "label": "Shareable", "color": "green" }
]
},
"commentDate": {
"name": "Comment date",
"type": "timestamp"
},
"body": {
"name": "Body",
"type": "text",
"variant": "long-text"
}
}
}If your client already has the created legal-case definition id, you can replace caseRecordId with:
{
"caseId": {
"name": "Case",
"type": "relationship",
"dataDefinitionId": "<legal-case-definition-id>"
}
}3. Example create requests
Create the definitions with POST /api/v1/data-definitions.
Current API note:
- the create payload uses
name,description, andfields - the handle is derived from
name - data rows are created with
data, notattributes
Example for legal-case:
curl -X POST "https://andibase.com/api/v1/data-definitions" \
-H "Authorization: Bearer $ANDI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Legal Case",
"description": "Tracks one legal matter, its current status, and operational details.",
"fields": {
"title": { "name": "Title", "type": "text" },
"caseNumber": { "name": "Case number", "type": "text" },
"clientName": { "name": "Client name", "type": "text" },
"status": {
"name": "Status",
"type": "select",
"options": [
{ "value": "intake", "label": "Intake", "color": "sky" },
{ "value": "active", "label": "Active", "color": "green" },
{ "value": "closed", "label": "Closed", "color": "stone" }
]
},
"summary": { "name": "Summary", "type": "text", "variant": "long-text" },
"details": { "name": "Details", "type": "text", "variant": "long-text" },
"commentsCount": { "name": "Comments count", "type": "number" }
}
}'Create sample case rows with POST /api/v1/data-definitions/legal-case/data/upsert-many.
curl -X POST "https://andibase.com/api/v1/data-definitions/legal-case/data/upsert-many" \
-H "Authorization: Bearer $ANDI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"items": [
{
"data": {
"title": "Acme vendor dispute",
"caseNumber": "ACME-2026-014",
"clientName": "Acme Corp",
"matterType": "litigation",
"jurisdiction": "Chile",
"courtOrForum": "Santiago Civil Court",
"assignedOwner": "María Soto",
"priority": "high",
"status": "active",
"openedOn": "2026-03-20",
"nextDeadline": "2026-03-28",
"summary": "Vendor payment dispute involving alleged delivery delays.",
"details": "Initial pleadings filed. Awaiting response deadlines and supporting invoices.",
"commentsCount": 0
}
}
]
}'Step 2: Create the legal case agent
Create one workspace agent with handle legal-case-agent.
Recommended behavior:
- create new legal case records from structured or natural-language intake
- update case status, owner, deadlines, and narrative details
- add timeline comments when users describe a new development
- keep
commentsCount,lastCommentAt, andlastUpdateSummaryon the linked case in sync when it creates a comment - summarize the latest operational state clearly without giving legal advice
Recommended agent payload:
{
"name": "Legal Case Agent",
"handle": "legal-case-agent",
"description": "Tracks legal matters, comments, deadlines, and status updates.",
"model": "openai/gpt-5.4",
"capabilities": {
"webAccess": false,
"browserAccess": false,
"objectsAccess": true
},
"instructions": "You manage a workspace legal case tracker. Use the legal-case and legal-case-comment data definitions as the source of truth. Be action-oriented: make reasonable assumptions, complete the work when the intended outcome is clear, and avoid asking for confirmation on every small step. Create new case records when the user describes a new matter. Update existing case rows when the user changes status, ownership, deadlines, summary, or details. When the user provides a case update or note, create a legal-case-comment row linked to the correct case, then update the linked case commentsCount, lastCommentAt, and lastUpdateSummary. If multiple cases could match, ask one short clarifying question. Do not provide legal advice or legal conclusions. Stay focused on tracking, organization, and operational summaries."
}Create the agent with POST /api/v1/agents.
Step 3: Create the mini app
Create one app with handle legal-case-tracker.
Mini app requirements:
- read cases and comments using
window.andibase.fetch - show a list of cases
- show one selected case detail view
- display summary, details, metadata, and comments for the selected case
- allow a member to update status for the selected case
- allow a member to add a new comment for the selected case
- keep styling simple and flat
- prefer shared
@andibase/uicomponents with default styles
Current API note:
POST /api/v1/appscurrently acceptsname,handle,description, andcode- app access fields such as
memberRoleandpublicPermissionsare server-managed defaults on create - new apps are created as member-only by default
Example app code:
import { useEffect, useMemo, useState } from "react";
import { Badge } from "@andibase/ui/badge";
import { Button } from "@andibase/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@andibase/ui/card";
import { Label } from "@andibase/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@andibase/ui/select";
import { Textarea } from "@andibase/ui/textarea";
const statusOptions = [
"intake",
"active",
"waiting-on-client",
"waiting-on-court",
"review",
"closed",
"archived",
];
async function fetchJson(path, init) {
const response = await window.andibase.fetch(path, init);
if (!response.ok) {
throw new Error(`Request failed with ${response.status}`);
}
return response.json();
}
export default function App() {
const [cases, setCases] = useState([]);
const [comments, setComments] = useState([]);
const [selectedCaseId, setSelectedCaseId] = useState(null);
const [nextStatus, setNextStatus] = useState("");
const [commentBody, setCommentBody] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
async function load() {
setLoading(true);
setError("");
try {
const [casesPayload, commentsPayload] = await Promise.all([
fetchJson("/data-definitions/legal-case/query?page=1&pageSize=100"),
fetchJson("/data-definitions/legal-case-comment/query?page=1&pageSize=200"),
]);
const nextCases = casesPayload.items ?? [];
const nextComments = commentsPayload.items ?? [];
setCases(nextCases);
setComments(nextComments);
setSelectedCaseId((current) => current ?? nextCases[0]?.id ?? null);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load cases");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
const selectedCase = useMemo(
() => cases.find((item) => item.id === selectedCaseId) ?? null,
[cases, selectedCaseId],
);
const selectedComments = useMemo(() => {
return comments
.filter((item) => item.data?.caseRecordId === selectedCaseId)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [comments, selectedCaseId]);
useEffect(() => {
setNextStatus(selectedCase?.data?.status ?? "");
}, [selectedCase?.id, selectedCase?.data?.status]);
async function updateStatus() {
if (!selectedCase || !nextStatus) return;
setSaving(true);
setError("");
try {
await fetchJson("/data-definitions/legal-case/data/patch-many", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: [
{
id: selectedCase.id,
data: {
status: nextStatus,
lastUpdateSummary: `Status changed to ${nextStatus}.`,
},
},
],
}),
});
await load();
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "Failed to update status");
} finally {
setSaving(false);
}
}
async function addComment() {
if (!selectedCase || !commentBody.trim()) return;
setSaving(true);
setError("");
try {
await fetchJson("/data-definitions/legal-case-comment/data/upsert-many", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: [
{
data: {
caseRecordId: selectedCase.id,
authorName: "Workspace member",
commentType: "note",
visibility: "internal",
commentDate: new Date().toISOString(),
body: commentBody.trim(),
},
},
],
}),
});
await fetchJson("/data-definitions/legal-case/data/patch-many", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: [
{
id: selectedCase.id,
data: {
commentsCount: Number(selectedCase.data?.commentsCount ?? 0) + 1,
lastCommentAt: new Date().toISOString(),
lastUpdateSummary: commentBody.trim(),
},
},
],
}),
});
setCommentBody("");
await load();
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : "Failed to add comment");
} finally {
setSaving(false);
}
}
if (loading) {
return <main style={{ padding: 24 }}>Loading...</main>;
}
return (
<main style={{ padding: 24 }}>
<div style={{ display: "grid", gap: 16 }}>
<h1>Legal case tracker</h1>
{error ? <p>{error}</p> : null}
<div
style={{
display: "grid",
gap: 16,
gridTemplateColumns: "minmax(240px, 320px) minmax(0, 1fr)",
}}
>
<Card>
<CardHeader>
<CardTitle>Cases</CardTitle>
</CardHeader>
<CardContent>
<div style={{ display: "grid", gap: 8 }}>
{cases.map((item) => (
<Button
key={item.id}
variant={item.id === selectedCaseId ? "default" : "outline"}
onClick={() => setSelectedCaseId(item.id)}
style={{ justifyContent: "flex-start" }}
>
{item.data?.title ?? item.id}
</Button>
))}
</div>
</CardContent>
</Card>
<div style={{ display: "grid", gap: 16 }}>
<Card>
<CardHeader>
<CardTitle>{selectedCase?.data?.title ?? "Select a case"}</CardTitle>
</CardHeader>
<CardContent>
{selectedCase ? (
<div style={{ display: "grid", gap: 12 }}>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Badge>{selectedCase.data?.status ?? "unknown"}</Badge>
<Badge variant="outline">{selectedCase.data?.priority ?? "no priority"}</Badge>
</div>
<div>
<strong>Client:</strong> {selectedCase.data?.clientName ?? "-"}
</div>
<div>
<strong>Case number:</strong> {selectedCase.data?.caseNumber ?? "-"}
</div>
<div>
<strong>Assigned owner:</strong> {selectedCase.data?.assignedOwner ?? "-"}
</div>
<div>
<strong>Next deadline:</strong> {selectedCase.data?.nextDeadline ?? "-"}
</div>
<div>
<strong>Summary:</strong>
<p>{selectedCase.data?.summary ?? "-"}</p>
</div>
<div>
<strong>Details:</strong>
<p>{selectedCase.data?.details ?? "-"}</p>
</div>
<div style={{ display: "grid", gap: 8, maxWidth: 280 }}>
<Label>Status</Label>
<Select value={nextStatus} onValueChange={setNextStatus}>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((status) => (
<SelectItem key={status} value={status}>
{status}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={updateStatus} disabled={saving || !nextStatus}>
Update status
</Button>
</div>
</div>
) : (
<p>No case selected.</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Comments</CardTitle>
</CardHeader>
<CardContent>
<div style={{ display: "grid", gap: 12 }}>
<Textarea
value={commentBody}
onChange={(event) => setCommentBody(event.target.value)}
placeholder="Add an internal case update or note."
rows={4}
/>
<Button onClick={addComment} disabled={saving || !selectedCase}>
Add comment
</Button>
<div style={{ display: "grid", gap: 8 }}>
{selectedComments.length > 0 ? (
selectedComments.map((item) => (
<Card key={item.id}>
<CardContent style={{ paddingTop: 16 }}>
<div style={{ display: "grid", gap: 8 }}>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Badge variant="outline">{item.data?.commentType ?? "note"}</Badge>
<Badge variant="outline">{item.data?.visibility ?? "internal"}</Badge>
</div>
<div>{item.data?.body ?? "-"}</div>
</div>
</CardContent>
</Card>
))
) : (
<p>No comments yet.</p>
)}
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</main>
);
}Create the app with POST /api/v1/apps.
Recommended acceptance checks
An agent should consider the recipe complete only if all of the following are true:
legal-caseexists and includes the required fields.legal-case-commentexists and includes either thecaseIdrelationship or thecaseRecordIdfallback used by the current public HTTP API flow.legal-case-agentexists.legal-case-trackerexists.- At least one sample legal-case row can be created successfully.
- At least one sample legal-case-comment row can be created successfully.
- The app loads, shows case details, accepts a new comment, and updates case status without runtime auth errors.
Minimal delivery summary
When the agent finishes, it should report:
- the created data definition handles
- the created agent handle
- the created app handle
- whether the app is member-only
- any assumptions made for status workflow, matter type defaults, or comment visibility