diff --git a/__tests__/hooks/useFlows.test.tsx b/__tests__/hooks/useFlows.test.tsx
new file mode 100644
index 0000000..d1faa77
--- /dev/null
+++ b/__tests__/hooks/useFlows.test.tsx
@@ -0,0 +1,103 @@
+/**
+ * Tests for useFlows — validates flow metadata fetch + normalization
+ * (label fallback, node/edge/variable mapping) and the error path.
+ */
+import React from "react";
+import { renderHook, waitFor } from "@testing-library/react-native";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+/* ---- Mock the authenticated fetch from lib/objectstack ---- */
+const mockApiFetch = jest.fn();
+jest.mock("~/lib/objectstack", () => ({
+ apiFetch: (...args: unknown[]) => mockApiFetch(...args),
+}));
+
+import { useFlows, useFlow } from "~/hooks/useFlows";
+
+function wrapper({ children }: { children: React.ReactNode }) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return {children};
+}
+
+function jsonResponse(body: unknown, ok = true, status = 200) {
+ return { ok, status, json: async () => body } as unknown as Response;
+}
+
+beforeEach(() => {
+ mockApiFetch.mockReset();
+});
+
+describe("useFlows", () => {
+ it("fetches and normalizes flow definitions", async () => {
+ mockApiFetch.mockResolvedValue(
+ jsonResponse({
+ type: "flow",
+ items: [
+ {
+ name: "lead_conversion",
+ label: "Lead Conversion Process",
+ description: "Convert leads",
+ version: 1,
+ status: "draft",
+ type: "screen",
+ variables: [{ name: "leadId", type: "text", isInput: true }],
+ nodes: [
+ { id: "start", type: "start", label: "Start" },
+ { id: "create", type: "create_record", label: "Create Account" },
+ ],
+ edges: [{ id: "e1", source: "start", target: "create" }],
+ },
+ // Missing label → falls back to name; missing arrays → empty.
+ { name: "bare_flow" },
+ // No name → filtered out.
+ { label: "ghost" },
+ ],
+ }),
+ );
+
+ const { result } = renderHook(() => useFlows(), { wrapper });
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(mockApiFetch).toHaveBeenCalledWith("/api/v1/meta/flow");
+ const flows = result.current.data!;
+ expect(flows).toHaveLength(2);
+
+ const lead = flows[0];
+ expect(lead.label).toBe("Lead Conversion Process");
+ expect(lead.nodes).toHaveLength(2);
+ expect(lead.edges[0]).toMatchObject({ source: "start", target: "create" });
+ expect(lead.variables[0]).toMatchObject({ name: "leadId", isInput: true });
+
+ const bare = flows[1];
+ expect(bare.label).toBe("bare_flow"); // label fallback
+ expect(bare.nodes).toEqual([]);
+ expect(bare.edges).toEqual([]);
+ });
+
+ it("surfaces an error when the request fails", async () => {
+ mockApiFetch.mockResolvedValue(jsonResponse(null, false, 500));
+
+ const { result } = renderHook(() => useFlows(), { wrapper });
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(result.current.error?.message).toMatch(/HTTP 500/);
+ });
+});
+
+describe("useFlow", () => {
+ it("selects a single flow by name from the cached list", async () => {
+ mockApiFetch.mockResolvedValue(
+ jsonResponse({
+ items: [
+ { name: "a", label: "Alpha", nodes: [], edges: [] },
+ { name: "b", label: "Beta", nodes: [], edges: [] },
+ ],
+ }),
+ );
+
+ const { result } = renderHook(() => useFlow("b"), { wrapper });
+ await waitFor(() => expect(result.current.flow).toBeDefined());
+ expect(result.current.flow?.label).toBe("Beta");
+ });
+});
diff --git a/app/(tabs)/more.tsx b/app/(tabs)/more.tsx
index e873984..1f42d40 100644
--- a/app/(tabs)/more.tsx
+++ b/app/(tabs)/more.tsx
@@ -6,6 +6,7 @@ import {
Globe,
LogOut,
ChevronRight,
+ Workflow,
} from "lucide-react-native";
import { useRouter } from "expo-router";
import { authClient } from "~/lib/auth-client";
@@ -104,6 +105,14 @@ export default function MoreScreen() {
onPress={() => router.push("/(tabs)/notifications")}
/>
+ {/* Automation */}
+
+ }
+ label="Flows"
+ onPress={() => router.push("/flows")}
+ />
+
{/* Preferences */}
+
diff --git a/app/flows/[name].tsx b/app/flows/[name].tsx
new file mode 100644
index 0000000..f1b48e9
--- /dev/null
+++ b/app/flows/[name].tsx
@@ -0,0 +1,79 @@
+import { View, Text, ScrollView } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { useLocalSearchParams } from "expo-router";
+import { Workflow } from "lucide-react-native";
+import { ScreenHeader } from "~/components/common/ScreenHeader";
+import { Badge } from "~/components/ui/Badge";
+import { EmptyState } from "~/components/ui/EmptyState";
+import { ListSkeleton } from "~/components/ui/ListSkeleton";
+import { FlowViewer } from "~/components/automation/FlowViewer";
+import { useFlow } from "~/hooks/useFlows";
+
+function humanize(token: string): string {
+ return token.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+/**
+ * Flow detail — renders a read-only `FlowViewer` diagram of a single automation
+ * flow's nodes and edges, plus its trigger/status metadata.
+ */
+export default function FlowDetailScreen() {
+ const { name } = useLocalSearchParams<{ name: string }>();
+ const flowName = Array.isArray(name) ? name[0] : name;
+ const { flow, isLoading, error } = useFlow(flowName);
+
+ if (isLoading) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (error || !flow) {
+ return (
+
+
+
+
+ );
+ }
+
+ const inputs = flow.variables.filter((v) => v.isInput);
+
+ return (
+
+
+
+ {/* Metadata summary */}
+
+ {flow.description ? (
+ {flow.description}
+ ) : null}
+
+ {flow.type ? {humanize(flow.type)} : null}
+
+ {flow.status ? humanize(flow.status) : flow.active ? "Active" : "Inactive"}
+
+ {typeof flow.version === "number" ? (
+ {`v${flow.version}`}
+ ) : null}
+ {inputs.length > 0 ? (
+ {`${inputs.length} input${inputs.length === 1 ? "" : "s"}`}
+ ) : null}
+
+
+
+ {/* Flow diagram */}
+
+
+
+
+ );
+}
diff --git a/app/flows/index.tsx b/app/flows/index.tsx
new file mode 100644
index 0000000..a50e2d0
--- /dev/null
+++ b/app/flows/index.tsx
@@ -0,0 +1,90 @@
+import { View, Text, ScrollView } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { useRouter } from "expo-router";
+import { Workflow } from "lucide-react-native";
+import { ScreenHeader } from "~/components/common/ScreenHeader";
+import { PressableCard } from "~/components/ui/PressableCard";
+import { Badge } from "~/components/ui/Badge";
+import { EmptyState } from "~/components/ui/EmptyState";
+import { ListSkeleton } from "~/components/ui/ListSkeleton";
+import { useFlows, type FlowDefinition } from "~/hooks/useFlows";
+
+/** Title-case a flow trigger type / status token (`record_change` → "Record Change"). */
+function humanize(token: string): string {
+ return token.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+function FlowCard({ flow, onPress }: { flow: FlowDefinition; onPress: () => void }) {
+ const stepCount = flow.nodes.length;
+ return (
+
+ {flow.label}
+ {flow.description ? (
+
+ {flow.description}
+
+ ) : null}
+
+ {flow.type ? {humanize(flow.type)} : null}
+
+ {flow.status ? humanize(flow.status) : flow.active ? "Active" : "Inactive"}
+
+ {`${stepCount} step${stepCount === 1 ? "" : "s"}`}
+
+
+ );
+}
+
+/**
+ * Automation Flows — lists every flow the connected server exposes and links to
+ * a read-only diagram of each. Surfaces the `FlowViewer` that previously had no
+ * route into the app.
+ */
+export default function FlowsScreen() {
+ const router = useRouter();
+ const { data: flows, isLoading, error, refetch, isRefetching } = useFlows();
+
+ const count = flows?.length ?? 0;
+
+ return (
+
+ 0 ? `${count} flow${count === 1 ? "" : "s"}` : undefined}
+ />
+ {isLoading ? (
+
+ ) : error ? (
+ void refetch()}
+ actionLoading={isRefetching}
+ />
+ ) : count === 0 ? (
+
+ ) : (
+
+ {flows!.map((flow) => (
+ router.push(`/flows/${encodeURIComponent(flow.name)}`)}
+ />
+ ))}
+
+ )}
+
+ );
+}
diff --git a/hooks/useFlows.ts b/hooks/useFlows.ts
new file mode 100644
index 0000000..67ed85e
--- /dev/null
+++ b/hooks/useFlows.ts
@@ -0,0 +1,128 @@
+import { useQuery, type UseQueryResult } from "@tanstack/react-query";
+import { apiFetch } from "~/lib/objectstack";
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+export interface FlowNode {
+ id: string;
+ type: string;
+ label: string;
+ config?: Record;
+}
+
+export interface FlowEdge {
+ id: string;
+ source: string;
+ target: string;
+ label?: string;
+}
+
+export interface FlowVariable {
+ name: string;
+ type?: string;
+ isInput?: boolean;
+ isOutput?: boolean;
+}
+
+/**
+ * A flow definition as returned by the metadata API (`/api/v1/meta/flow`).
+ * Shape mirrors `@objectstack/spec` flow metadata: a list of `nodes` plus the
+ * `edges` connecting them, which map directly onto the `FlowViewer` diagram.
+ */
+export interface FlowDefinition {
+ name: string;
+ label: string;
+ description?: string;
+ version?: number;
+ status?: string;
+ /** Trigger kind: `schedule`, `record`, `manual`, … */
+ type?: string;
+ active?: boolean;
+ variables: FlowVariable[];
+ nodes: FlowNode[];
+ edges: FlowEdge[];
+}
+
+/* ------------------------------------------------------------------ */
+/* Fetch + normalize */
+/* ------------------------------------------------------------------ */
+
+function normalizeFlow(raw: Record): FlowDefinition {
+ const nodes = Array.isArray(raw.nodes) ? (raw.nodes as Record[]) : [];
+ const edges = Array.isArray(raw.edges) ? (raw.edges as Record[]) : [];
+ const variables = Array.isArray(raw.variables)
+ ? (raw.variables as Record[])
+ : [];
+ const name = String(raw.name ?? "");
+ return {
+ name,
+ label: String(raw.label ?? name),
+ description: raw.description ? String(raw.description) : undefined,
+ version: typeof raw.version === "number" ? raw.version : undefined,
+ status: raw.status ? String(raw.status) : undefined,
+ type: raw.type ? String(raw.type) : undefined,
+ active: typeof raw.active === "boolean" ? raw.active : undefined,
+ variables: variables.map((v) => ({
+ name: String(v.name ?? ""),
+ type: v.type ? String(v.type) : undefined,
+ isInput: v.isInput === true,
+ isOutput: v.isOutput === true,
+ })),
+ nodes: nodes.map((n, i) => ({
+ id: String(n.id ?? `node-${i}`),
+ type: String(n.type ?? "step"),
+ label: String(n.label ?? n.id ?? `Step ${i + 1}`),
+ config: (n.config as Record) ?? undefined,
+ })),
+ edges: edges.map((e, i) => ({
+ id: String(e.id ?? `edge-${i}`),
+ source: String(e.source ?? ""),
+ target: String(e.target ?? ""),
+ label: e.label ? String(e.label) : undefined,
+ })),
+ };
+}
+
+async function fetchFlows(): Promise {
+ const res = await apiFetch("/api/v1/meta/flow");
+ if (!res.ok) {
+ throw new Error(`Failed to load flows (HTTP ${res.status})`);
+ }
+ const json = (await res.json()) as { items?: unknown };
+ const items = Array.isArray(json?.items) ? (json.items as Record[]) : [];
+ return items.map(normalizeFlow).filter((f) => f.name.length > 0);
+}
+
+/* ------------------------------------------------------------------ */
+/* Hooks */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Load all automation flow definitions exposed by the connected server.
+ * Backed by react-query so the list and any detail view share one fetch.
+ */
+export function useFlows(): UseQueryResult {
+ return useQuery({
+ queryKey: ["flows"],
+ queryFn: fetchFlows,
+ staleTime: 60_000,
+ });
+}
+
+/**
+ * Convenience selector for a single flow by name, reusing the cached list.
+ */
+export function useFlow(name: string | undefined): {
+ flow: FlowDefinition | undefined;
+ isLoading: boolean;
+ error: Error | null;
+} {
+ const { data, isLoading, error } = useFlows();
+ return {
+ flow: name ? data?.find((f) => f.name === name) : undefined,
+ isLoading,
+ error: error ?? null,
+ };
+}