andibase

Legal Case Tracker

Agent-ready recipe for creating a legal case tracker in andibase

Open Markdown

This recipe tells an agent exactly how to build a legal case tracker in andibase.

Goal

Create four things in one workspace:

  1. A legal-case data definition.
  2. A legal-case-comment data definition.
  3. One agent that helps users manage case records and updates.
  4. 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-case definition for matters and their current state
  • a legal-case-comment definition for case notes and updates
  • a legal-case-agent agent
  • a legal-case-tracker mini app

Use the API documented in:

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:

  1. Create the legal-case definition first.
  2. Create the legal-case-comment definition second.
  3. In the current API, data definition handles are generated from name. Use name: "Legal Case" and name: "Legal Case Comment" so the created handles normalize to legal-case and legal-case-comment.
  4. If your execution environment can resolve the created legal-case definition id, wire the comment-to-case link as a relationship field. If it cannot, use a text foreign key such as caseRecordId instead, because the public HTTP API still expects dataDefinitionId for relationship fields.
  5. Create the legal-case agent with instructions focused on case intake, status updates, and timeline hygiene.
  6. Create the mini app last.
  7. Do not invent additional legal workflows, billing logic, or compliance fields unless the user explicitly asks for them.
  8. 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.

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: medium
  • status: intake
  • commentsCount: 0
  • openedOn: today

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, and fields
  • the handle is derived from name
  • data rows are created with data, not attributes

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
        }
      }
    ]
  }'

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, and lastUpdateSummary on 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/ui components with default styles

Current API note:

  • POST /api/v1/apps currently accepts name, handle, description, and code
  • app access fields such as memberRole and publicPermissions are 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.

An agent should consider the recipe complete only if all of the following are true:

  1. legal-case exists and includes the required fields.
  2. legal-case-comment exists and includes either the caseId relationship or the caseRecordId fallback used by the current public HTTP API flow.
  3. legal-case-agent exists.
  4. legal-case-tracker exists.
  5. At least one sample legal-case row can be created successfully.
  6. At least one sample legal-case-comment row can be created successfully.
  7. 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

On this page