logo-darkPipe0
Agents & SDKs

Headless UI (Beta)

React components for rendering pipe0 forms and catalog pickers.

@pipe0/react ships two headless libraries: form rendering and catalog. Both follow the same pattern — a root component + compound parts + a render={(props, state) => ReactNode} escape hatch.

Install

pnpm i @pipe0/react

Import the stylesheet once at the app root:

import "@pipe0/react/styles";

Form rendering

Renders the configuration form for a single pipe or search.

Zero-config

Pass pipeId, publicKey, and onSubmit. The form renders all sections and a submit button by default.

import { PipeForm } from "@pipe0/react";

<PipeForm
  pipeId="json:extract@1"
  publicKey="pk_abc..."
  onSubmit={(payload) => console.log(payload)}
/>;

<SearchForm searchId={…} publicKey={…} onSubmit={…} /> is identical, paired with useSearchForm.

Compound parts

Drive layout yourself with usePipeForm. With no children, <PipeForm> falls back to <PipeFormContent /> + <PipeFormSubmitButton />.

PartPurpose
PipeFormHeader / PipeFormTitleTop of the form.
PipeFormContentIterates sections.
PipeFormSection (section prop)One section wrapper.
PipeFormGroup (group prop)One group inside a section.
PipeFormField (field prop)Renders the right adapter for the field kind.
PipeFormSubmitButtonSubmit button bound to the form state.
PipeFormFooterWraps the submit button area.
PipeFormErrorsTop-level submit errors.
import {
  PipeForm,
  PipeFormContent,
  PipeFormSection,
  PipeFormGroup,
  PipeFormSubmitButton,
  usePipeForm,
} from "@pipe0/react";

function MyForm({ publicKey, handle }) {
  const pipeForm = usePipeForm({ pipeId: "json:extract@1", publicKey });

  return (
    <PipeForm context={pipeForm} onSubmit={handle}>
      <PipeFormContent>
        {pipeForm.sections.map((s) => (
          <PipeFormSection key={s.key} section={s}>
            {s.groups.map((g) => (
              <PipeFormGroup key={g.key} group={g} />
            ))}
          </PipeFormSection>
        ))}
      </PipeFormContent>
      <PipeFormSubmitButton />
    </PipeForm>
  );
}

Render prop

Every compound part accepts render={(props, state) => ReactNode}. Spread {...props} to keep refs, handlers, className, and data-* wired; use state to rebuild the markup.

<PipeFormContent
  render={(props, { sections }) => (
    <div {...props} className="grid gap-6">
      {sections.map((section) => (
        <PipeFormSection key={section.key} section={section} />
      ))}
    </div>
  )}
/>

State by part:

PartState
PipeFormContent{ sections, fieldPaths, hasFieldLoaderError, isFieldLoaderLoading, form }
PipeFormSection{ groups, hasErrors }
PipeFormGroup{ fields, expanded }
PipeFormFieldadapter resolved from field.kind

Styling and field adapters

Wrap a subtree with FormProvider to inject classNames (slot map) and adapters (per-kind component map). Nested providers shallow-merge.

import { FormProvider, PipeForm } from "@pipe0/react";
import type { FieldAdapterMap, FormClassNames } from "@pipe0/react";

const classNames: FormClassNames = {
  label: "text-sm font-medium",
  field: "space-y-1",
  error: "text-red-600",
};

const adapters: FieldAdapterMap = {
  text_input: ({ field }) => (
    <input
      value={field.value ?? ""}
      onChange={(e) => field.setValue(e.target.value)}
      disabled={field.disabled}
    />
  ),
};

<FormProvider classNames={classNames} adapters={adapters}>
  <PipeForm pipeId="json:extract@1" publicKey={pk} onSubmit={handle} />
</FormProvider>;

Adapter kinds include: text_input, textarea_input, select_input, multi_select_input, boolean_input, int_input, number_input, range_input, date_range_input, connector_input, providers_input, prompt_input, template_input, json_schema_input, pipes_run_if_input. Only override the kinds you care about — the rest fall through to defaults.

Common class slots: form, section, sectionLabel, sectionDescription, group, groupHeader, groupContent, field, label, description, error, plus per-kind slots (textInput, selectInput, …).

Visibility

Control which sections, groups, and fields render. null hides.

const pipeForm = usePipeForm({
  pipeId,
  publicKey,
  sectionMap: { connector: null },
  groupMap: { advanced: { label: "Advanced", defaultExpand: false } },
  pathMap: { "input.raw": null },
});

pipeForm.sections already reflects these maps.

Resolvers

Fetch user-specific options (connections, audiences, locations, …).

import type { FormResolvers } from "@pipe0/base";

const resolvers: FormResolvers = {
  getConnections: async () => {
    const res = await fetch("/api/pipe0/connections");
    return res.json(); // [{ public_id, provider }]
  },
  getFieldContext: async ({ fieldPath, query, payload }) => {
    const res = await fetch("/api/pipe0/context", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ fieldPath, query, payload }),
    });
    return res.json();
  },
};
<PipeForm
  pipeId="email:send:resend@1"
  publicKey="pk_abc..."
  resolvers={resolvers}
  onSubmit={handle}
/>

Resolvers must proxy through your backend.

publicKey identifies your project but does not authorize resolver requests. The underlying endpoints require your server-side API key and need to know which of your users is asking. Your resolver hits your backend, which authenticates the user and forwards the call to pipe0.

export async function POST(req: Request) {
  const user = await requireUser(req); // your auth
  const body = await req.json();

  const res = await fetch("https://api.pipe0.com/v1/pipes/field-context", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.PIPE0_API_KEY!}`,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      field_path: body.fieldPath,
      query: body.query,
      payload: body.payload,
    }),
  });

  return new Response(await res.text(), { status: res.status });
}

Catalog

The picker users navigate to choose a pipe or search. Three variants share the same shape: PipeCatalog, SearchCatalog, SearchesCatalog.

Zero-config

import { PipeCatalog } from "@pipe0/react";

<PipeCatalog onSelectPipe={(pipeId) => console.log(pipeId)} />;

Optional props: filter (whitelist/blacklist of pipes or providers — memoize it), initialColumnFilters, classNames, components.

Compound parts

Drive the catalog with usePipeCatalogTable. Without children, the root renders search filter → category filter → column filters → active filters → list → empty by default.

PartPurpose
PipeCatalogSearchFilterFree-text search input.
PipeCatalogCategoryFilterCategory tabs.
PipeCatalogColumnFiltersAll column filters in one row.
PipeCatalogInputFieldFilterTyped wrapper (pipe-only).
PipeCatalogOutputFieldFilterTyped wrapper.
PipeCatalogProviderFilterTyped wrapper.
PipeCatalogTagFilterTyped wrapper.
PipeCatalogActiveFiltersChips for currently-applied filters.
PipeCatalogListThe list/grid of cards.
PipeCatalogCardPer-row card slot.
PipeCatalogEmptyEmpty-state slot.
import {
  PipeCatalog,
  PipeCatalogSearchFilter,
  PipeCatalogCategoryFilter,
  PipeCatalogProviderFilter,
  PipeCatalogActiveFilters,
  PipeCatalogList,
  PipeCatalogEmpty,
  usePipeCatalogTable,
} from "@pipe0/react";

function PipePicker({ onSelect }) {
  const ctx = usePipeCatalogTable();
  return (
    <PipeCatalog context={ctx} onSelectPipe={onSelect}>
      <PipeCatalogSearchFilter />
      <PipeCatalogCategoryFilter />
      <PipeCatalogProviderFilter />
      <PipeCatalogActiveFilters />
      <PipeCatalogList />
      <PipeCatalogEmpty />
    </PipeCatalog>
  );
}

Render prop

Same (props, state) contract as forms — useful when you want to swap a filter UI without rebuilding the table logic.

<PipeCatalogProviderFilter
  render={(props, { value, setValue, options }) => (
    <MyProviderPicker
      {...props}
      selected={value}
      onChange={setValue}
      options={options}
    />
  )}
/>

State by part:

PartState
*CatalogSearchFilter{ value, setValue, isActive }
*CatalogCategoryFilter{ value, setValue, options, counts, totalCount, isActive }
*CatalogColumnFilter + typed wrappers{ value, setValue, options, isActive }
*CatalogList{ cards, isEmpty }
*CatalogActiveFilters{ activeFilters, isEmpty } (each: { id, value, label, remove })
*CatalogCard{ selected, expanded, setExpanded }

Styling and component swaps

CatalogProvider injects classNames and components (Card, EmptyState, Badge) for a subtree. Nested providers shallow-merge.

import { CatalogProvider, PipeCatalog } from "@pipe0/react";

<CatalogProvider
  classNames={{ filterSelect: "border-zinc-300", card: "rounded-xl" }}
  components={{ EmptyState: () => <p>No matches.</p> }}
>
  <PipeCatalog onSelectPipe={handle} />
</CatalogProvider>;

Common class slots: root, searchInput, filters, filterSelect, categoryFilter, categoryButton, categoryButtonActive, activeFilters, activeFilterPill, card, cardHeader, cardLogo, cardLabel, cardDescription, cardBadges, badge, emptyState.

Custom card

Compose cards from primitives and self-binding widgets. Read row data with usePipeCatalogCard().

import {
  CatalogCard,
  CatalogCardHeader,
  CatalogCardTitle,
  CatalogCardDescription,
  CatalogCardBadges,
  CatalogCopyId,
  CatalogCreditBadge,
  CatalogProviderAvatars,
  PipeCatalogCard,
  usePipeCatalogCard,
} from "@pipe0/react";

function MyPipeCard() {
  const card = usePipeCatalogCard();
  return (
    <CatalogCard>
      <CatalogCardHeader>
        <CatalogProviderAvatars />
        <CatalogCardTitle>{card.label}</CatalogCardTitle>
        <CatalogCopyId />
      </CatalogCardHeader>
      <CatalogCardDescription>{card.description}</CatalogCardDescription>
      <CatalogCardBadges>
        <CatalogCreditBadge />
      </CatalogCardBadges>
    </CatalogCard>
  );
}

<PipeCatalogCard render={() => <MyPipeCard />} />;

Card data is pre-derived: label, description, providers, startingCostPerProvider, startingCreditAmount, defaultOutputFields, docPath, plus entry / latestEntry escape hatches. Don't re-derive these from raw catalog entries.

Derived widgets that read from card context: CatalogCopyId, CatalogCreditBadge, CatalogDocsBadge, CatalogFieldBadge, CatalogProviderAvatars.

Search variants

SearchCatalog and SearchesCatalog mirror PipeCatalog exactly — paired with useSearchCatalogTable / useSearchesCatalogTable, using onSelectSearch / onSelectSearches, and reading rows via useSearchCatalogCard() / useSearchesCatalogCard(). Neither exposes InputFieldFilter (searches have no input fields).

On this page