Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/khaki-plums-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@plotday/twister": minor
---

Added: Options tool for defining user-configurable options for twists
5 changes: 5 additions & 0 deletions twister/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
"types": "./dist/tag.d.ts",
"default": "./dist/tag.js"
},
"./options": {
"@plotday/source": "./src/options.ts",
"types": "./dist/options.d.ts",
"default": "./dist/options.js"
},
"./tools/twists": {
"@plotday/source": "./src/tools/twists.ts",
"types": "./dist/tools/twists.d.ts",
Expand Down
1 change: 1 addition & 0 deletions twister/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export * from "./plot";
export * from "./tag";
export * from "./tool";
export * from "./tools";
export * from "./options";
export * from "./utils/types";
export { getBuilderDocumentation } from "./creator-docs";
112 changes: 112 additions & 0 deletions twister/src/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { ITool } from "./tool";

/**
* A select option definition for twist configuration.
* Renders as a dropdown in the Flutter UI.
*/
export type SelectDef = {
type: "select";
label: string;
description?: string;
choices: ReadonlyArray<{ value: string; label: string }>;
default: string;
};

/**
* A text input option definition for twist configuration.
* Renders as a text field in the Flutter UI.
*/
export type TextDef = {
type: "text";
label: string;
description?: string;
default: string;
placeholder?: string;
};

/**
* A number input option definition for twist configuration.
* Renders as a number field in the Flutter UI.
*/
export type NumberDef = {
type: "number";
label: string;
description?: string;
default: number;
min?: number;
max?: number;
};

/**
* A boolean toggle option definition for twist configuration.
* Renders as a switch in the Flutter UI.
*/
export type BooleanDef = {
type: "boolean";
label: string;
description?: string;
default: boolean;
};

/**
* Union of all option definition types.
*/
export type OptionDef = SelectDef | TextDef | NumberDef | BooleanDef;

/**
* Schema defining all configurable options for a twist.
* Each key maps to an option definition that describes its type, label, and default value.
*/
export type OptionsSchema = Record<string, OptionDef>;

/**
* Infers the resolved value types from an options schema.
* Boolean options resolve to `boolean`, number options to `number`,
* and select/text options to `string`.
*/
export type ResolvedOptions<T extends OptionsSchema> = {
[K in keyof T]: T[K] extends BooleanDef
? boolean
: T[K] extends NumberDef
? number
: string;
};

/**
* Built-in marker class for twist configuration options.
*
* Declare options in your twist's `build()` method to expose configurable
* settings to users. The schema is introspected at deploy time and stored
* alongside permissions. At runtime, user values are merged with defaults.
*
* @example
* ```typescript
* import { Options, type OptionsSchema } from "@plotday/twister/options";
*
* export default class MyTwist extends Twist<MyTwist> {
* build(build: ToolBuilder) {
* return {
* options: build(Options, {
* model: {
* type: 'select',
* label: 'AI Model',
* choices: [
* { value: 'fast', label: 'Fast' },
* { value: 'smart', label: 'Smart' },
* ],
* default: 'fast',
* },
* }),
* // ... other tools
* };
* }
*
* async respond(note: Note) {
* const model = this.tools.options.model; // typed as string
* }
* }
* ```
*/
export abstract class Options extends ITool {
static readonly toolId = "Options";
}
15 changes: 11 additions & 4 deletions twister/src/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*
* @internal
*/
import type { Options, OptionsSchema, ResolvedOptions } from "../options";
import type { Callbacks } from "../tools/callbacks";
import type { Store } from "../tools/store";
import type { Tasks } from "../tools/tasks";
Expand Down Expand Up @@ -63,10 +64,16 @@ export type InferOptions<T> = T extends {
* Function type for building tool dependencies.
* Used in build methods to request tool instances.
*/
export type ToolBuilder = <TC extends abstract new (...args: any) => any>(
ToolClass: TC,
options?: InferOptions<TC>
) => Promise<InstanceType<TC>>;
export type ToolBuilder = {
<T extends OptionsSchema>(
ToolClass: typeof Options,
schema: T
): Promise<ResolvedOptions<T>>;
<TC extends abstract new (...args: any) => any>(
ToolClass: TC,
options?: InferOptions<TC>
): Promise<InstanceType<TC>>;
};

/**
* Interface for managing tool initialization and lifecycle.
Expand Down
20 changes: 18 additions & 2 deletions twists/chat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,28 @@ import {
type ToolBuilder,
Twist,
} from "@plotday/twister";
import { AI, type AIMessage } from "@plotday/twister/tools/ai";
import { Options } from "@plotday/twister/options";
import { AI, type AIMessage, AIModel } from "@plotday/twister/tools/ai";
import { ActivityAccess, Plot } from "@plotday/twister/tools/plot";

export default class ChatTwist extends Twist<ChatTwist> {
build(build: ToolBuilder) {
return {
options: build(Options, {
model: {
type: "select" as const,
label: "AI Model",
description: "The AI model used for chat responses",
choices: [
{ value: "anthropic/claude-sonnet-4-5", label: "Claude Sonnet 4.5" },
{ value: "anthropic/claude-haiku-4-5", label: "Claude Haiku 4.5 (Fast)" },
{ value: "openai/gpt-5", label: "GPT-5" },
{ value: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ value: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash (Fast)" },
],
default: "anthropic/claude-sonnet-4-5",
},
}),
ai: build(AI),
plot: build(Plot, {
activity: {
Expand Down Expand Up @@ -111,7 +127,7 @@ You can provide either or both inline and standalone links. Only use standalone
});

const response = await this.tools.ai.prompt({
model: { speed: "balanced", cost: "medium" },
model: { speed: "balanced", cost: "medium", hint: this.tools.options.model as AIModel },
messages,
outputSchema: schema,
});
Expand Down