Install
$ agentstack add skill-ripplo-claude-plugin-create ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
Create a Ripplo workflow
Fix broken app behavior in the app. Strengthen incomplete workflow declarations in the workflow. Read node_modules/@ripplo/testing/DSL.md before using an unfamiliar primitive.
A workflow is a durable model of a critical user journey, not a script for one fixture. Its given must describe the widest set of starting states from which the whole journey is valid. Preserve that generality in URLs, locators, inputs, branch conditions, and effects.
Prerequisite
Run npx ripplo doctor. The app dev server and npx ripplo daemon must be running.
Inspect:
.ripplo/state.tsfor schema-derived state handles.ripplo/givens.ts, when present, and feature givens for starting-state helpers.ripplo/workflows/for local authoring patterns- The app component, route, mutation, and data model for the journey
State model
One root Zod schema defines all application state. Its top level contains at most one HTTP source and one browser source. Each source returns its whole validated fragment in one read.
export const state = defineState(
z.object({
core: source.http(z.object({
organizations: setup.record(
z.object({
id: setup.generated(z.string()),
name: setup.value(z.string()),
}),
),
projects: setup.record(
z.object({
id: setup.generated(z.string()),
name: setup.value(z.string()),
organizationId: setup.value(z.string()),
}),
),
runs: setup.record(
z.object({
id: setup.generated(z.string()),
status: setup.value(z.enum(["passed", "failed"])),
workflowId: setup.value(z.string()),
}),
),
tasks: setup.record(
z.object({
id: setup.generated(z.string()),
projectId: setup.value(z.string()),
status: setup.value(z.enum(["open", "done"])),
title: setup.value(z.string()),
}),
),
workflows: setup.record(
z.object({
id: setup.generated(z.string()),
name: setup.value(z.string()),
projectId: setup.value(z.string()),
}),
),
})),
frontend: source.browser(z.object({
enabled: setup.value(z.boolean()),
note: setup.value(z.string().optional()),
selectedTaskId: setup.value(z.string().nullable()),
})),
}),
);
export const { organizations, projects, runs, tasks, workflows } = state.core;
setup.value()means a workflow can supply the value.setup.generated()means setup generates and returns the value.setup.record()means setup can create rows.- Plain Zod fields are observable only.
- Wrap each source schema with
source.http()orsource.browser(). - HTTP and browser sources use the same
read,setup.fields,setup.records, andteardown
contract.
- Implement
source.http()in the server process and mount its framework adapter. - Implement
source.browser()in the browser bundle and pass its engine toconnect(engine)
before rendering.
- A read-only source implements only
read. A setup-capable source also implementssetupand
teardown.
- Attach
createAuthenticationEngine()directly to an HTTP record handle when a workflow needs to
sign in. See /ripplo:setup for integration and ordering.
Workflow shape
One workflow is one user intent along the real click path from a natural entry point. Earlier steps create state later steps use. Set up only what the path cannot create.
const organizationActions = surface(menu("Organization actions"), { overlay: true });
const deleteOrganizationDialog = surface(dialog("Delete organization"), { overlay: true });
export const deleteOrganization = workflow("Delete an organization", () => {
const organization = required(organizations, {});
const remainingOrganization = optional(organizations, {});
const project = required(projects, { organizationId: organization.id });
const projectWorkflow = required(workflows, { projectId: project.id });
const projectRun = required(runs, { workflowId: projectWorkflow.id });
const organizationProjects = select(projects, {
organizationId: organization.id,
});
const organizationWorkflows = select(workflows, (candidate) =>
exists(select(organizationProjects, { id: candidate.projectId })),
);
const organizationRuns = select(runs, (candidate) =>
exists(select(organizationWorkflows, { id: candidate.workflowId })),
);
return {
given: [
viewport.desktop,
organization,
remainingOrganization,
project,
projectWorkflow,
projectRun,
closed(organizations),
],
steps: [
goto`/organizations/${organization.id}/overview`.expect(
visible(heading(organization.name)),
visible(button("Organization actions")),
),
click(button("Organization actions")).expect(
visible(organizationActions),
visible(inside(organizationActions, link("Settings"))),
),
click(inside(organizationActions, link("Settings"))).expect(
not(visible(organizationActions)),
url.path.is`/organizations/${organization.id}/settings`,
visible(heading("Organization settings")),
visible(button("Show danger zone")),
),
click(button("Show danger zone")).expect(visible(button("Delete organization"))),
click(button("Delete organization")).expect(
visible(deleteOrganizationDialog),
visible(inside(deleteOrganizationDialog, textbox("Organization name"))),
visible(inside(deleteOrganizationDialog, button("Delete organization"))),
),
fill(
inside(deleteOrganizationDialog, textbox("Organization name")),
organization.name,
).expect(
value(inside(deleteOrganizationDialog, textbox("Organization name")), organization.name),
enabled(inside(deleteOrganizationDialog, button("Delete organization"))),
),
click(inside(deleteOrganizationDialog, button("Delete organization"))).expect(
not(visible(deleteOrganizationDialog)),
deleted(organizationRuns),
deleted(organizationWorkflows),
deleted(organizationProjects),
deleted(select(organizations, { id: organization.id })),
when(
branch("deleting the last organization")
.if(equals(count(select(organizations, {})), exact(0)))
.expect(url.path.is`/organizations/new`, visible(heading("Create an organization"))),
branch("deleting one of several organizations").expect(
url.path.is`/organizations`,
visible(link(remainingOrganization.name)),
),
),
),
],
};
});
Every clicked element must be declared reachable by an earlier durable visible() assertion. Every mutation step declares its user-visible result and complete application-state effect.
Before authoring, state the journey in plain language:
- Intent: what the user is trying to accomplish
- Entry point: where that intent naturally begins
- Path: the real clicks and inputs, including intermediate mutations
- Preconditions: only state required for every step to remain valid
- Outcomes: every UI change and application-state effect, including cascades
If the description is “load this page and click this button,” zoom out until it captures the user’s actual goal. If two cases follow the same intent and click path but differ by starting state, keep them in one workflow with when. If the intent or path differs, use another workflow.
Starting state
Use the schema handles directly:
const task = required(tasks, { projectId: project.id, status: "open" });
const candidate = optional(tasks, { projectId: project.id, status: "open" });
const note = optional(state.frontend.note, arbitrary(state.frontend.note));
absent(tasks, { projectId: project.id, status: "archived" });
absent(state.frontend.note);
closed(tasks);
equals(state.frontend.selectedTaskId, task.id);
required()guarantees a matching row and returns typed field handles.optional()lets the solver choose presence to cover branches.absent()forbids a path or matching row.closed()says setup created every row in that collection.equals()requires a relationship or value at a path.arbitrary(field)creates a typed workflow input from the field's Zod domain.- Omit record fields when their generated values are not shared. Use the returned record field.
- Reuse one arbitrary binding across fields only when setup must make their values equal.
- Interpolate record fields in workflow URLs and element locators. Don't repeat exact setup values
in actions or expectations.
- Build locator numbers from live state with an
stemplate. Mark fixed product copy with
exact(...).
- Record references establish setup order. Missing dependencies and cycles are compile errors.
required, optional, absent, and closed describe meaningful state shape. Presence alone can change what the UI renders, so keep a constraint when the journey depends on that shape even if no later state assertion names it.
Start broad:
- Omit setup-capable record fields that do not affect the journey. Setup generates them from the
schema, and the returned record exposes their values.
- Use
arbitrary(field)when a workflow input may be any schema-valid value. Reuse the binding in
the browser action and the effect.
- Use
exact(value)only when that concrete string or number causes the behavior under test.
Enums, booleans, and null already have closed domains and stay bare.
- Use a relationship such as
gt,lte, ornot(equals(...))when behavior depends on a range or
change rather than one literal.
- Add
closed(record)only when the journey needs complete collection knowledge, usually for a
count or existence branch.
exact(...) is an explicit claim of behavioral significance, not an escape hatch for the compiler. Ask: would the journey still be valid with another schema-valid value? If yes, use a generated binding or a relation. If no, make the exact value’s role clear in the workflow.
Keep state-derived browser values connected to state:
const nextTitle = arbitrary(tasks.title);
const selectedTask = select(tasks, { id: task.id });
fill(textbox("Title"), nextTitle);
click(button("Save")).expect(
text(row(nextTitle), nextTitle),
updated(selectedTask, {
title: transform(({ after, before }) =>
and(equals(after, nextTitle), not(equals(after, before))),
),
}),
);
Fixed application copy such as button("Save") is fine. A record name, count, amount, slug, URL segment, or other value rendered from state must come from its handle or expression. For example, use button(s\Scope ${count(scopedWorkflows)}\), not button(exact("Scope 2")).
Branch sweep
At each step ask which starting state can change the result while preserving the same user intent and click path. Model that state with optional() or another broad constraint, then cover each reachable outcome with a named branch.
const maybeOpenTask = optional(tasks, {
projectId: project.id,
status: "open",
});
const openTasks = select(tasks, { projectId: project.id, status: "open" });
when(
branch("opening a project with tasks")
.if(gt(count(openTasks), exact(0)))
.expect(visible(list("Tasks"))),
branch("opening an empty project").expect(text(main(), "No open tasks")),
);
A selection used by a branch condition requires closed() for every queried collection. Otherwise unknown rows could change the answer. Branches are ordered. One unconditional fallback may appear last. Nested when() calls are invalid. Branch conditions constrain synthesis directly. Do not duplicate them as fixed starting-state values.
Include maybeOpenTask and closed(tasks) in given. The optional record gives synthesis a presence choice, while closed(tasks) makes the count complete.
State effects
select() is the only record filter:
const projectTasks = select(tasks, { projectId: project.id });
const openProjectTasks = select(projectTasks, { status: "open" });
updated(openProjectTasks, { status: "done" });
deleted(projectTasks);
Use choose(locator, value) to select an option in a browser control.
Selection membership is frozen before the action. Every selected row receives the effect. A selection used by updated() or deleted() must be provably nonempty at that step. Select through required or previously created records when one row must change. Use nested selection callbacks for relational cascades.
Creation is exhaustive:
const newTitle = arbitrary(tasks.title);
const task = created(tasks, {
projectId: project.id,
status: "open",
title: newTitle,
});
Supply every setup-capable field. Schema-generated fields are implicit. Use generated() when this action generates a field that the schema otherwise marks setup.value(). Use the zero-argument absent() value for an optional creation field that must be missing. The result exposes the complete observed row.
Scalar and record field transforms are relational:
transform(state.frontend.enabled, ({ after, before }) => equals(after, invert(before)));
updated(selectedTask, {
title: transform(({ after, before }) => not(equals(after, before))),
});
Use exact relations when the next value is known. Use broader relations only when the app chooses the value. Every effect must be provably changing from the state known at that step. Compilation rejects a possible no-op. Fix the model from the behavior:
- If the action chooses a new value, use a transform that excludes equality.
- If an earlier state decides whether the action changes anything, cover those cases with
when. - If the action is valid only under a real precondition, add the least-specific constraint that
proves it and preserve excluded behavior in another branch or journey.
- Do not hardcode a convenient previous value merely to make the proof pass.
Every observed change outside declared effects is a frame failure.
For a cascade, declare every affected collection:
const projectWorkflows = select(workflows, { projectId: project.id });
const workflowRuns = select(runs, (run) =>
exists(select(projectWorkflows, { id: run.workflowId })),
);
deleted(workflowRuns);
deleted(projectWorkflows);
deleted(select(projects, { id: project.id }));
Browser declarations
- Assert the new URL after navigation.
- Use accessible roles and app-owned names from source.
- Scope duplicate names with
inside(). - Model modal, drawer, menu, tab, and accordion containers with
surface(). - Use
exclusive()for mutually exclusive UI. - Declare both directions of stateful UI, such as Save appearing on edit and disappearing on save.
- Before
fill,choose,clear,check, oruncheckclaims another outcome, declare whether
the action changes the control. Use value(...) or not(value(...)) for value actions and checked(...) or not(checked(...)) for check actions. Add branches when the outcomes differ.
- Use
ephemeral()only for step-local evidence such as a toast. - Pair every application-state effect with user-visible evidence.
- Reuse state handles and expressions for state-rendered names, values, counts, and URL parts.
Authentication
actor.set(requiredActor) signs in at start or switches actors as a step. actor.anonymous starts signed out. The required record itself is actor identity. actor.is(requiredActor) and actor.is(actor.anonymous) declare explicit actor changes. Sign-in actions are self-verifying. An actor must come from an HTTP record collection with a matching authentication engine. Browser records cannot act as signed-in actors.
App-wide facts
Use fact() only for a true invariant or permission boundary that no single journey owns:
export const noCrashScreen = fact("the app never shows the crash screen").expect(
…
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [ripplo](https://github.com/ripplo)
- **Source:** [ripplo/claude-plugin](https://github.com/ripplo/claude-plugin)
- **License:** MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.