Skip to main content
from metalworks import Metalworks — one object you construct, and everything hangs off it. This page is the full reference: every public method, its signature, what it takes, and what it returns. New to the library? Start with the walkthrough; come here when you need the exact surface. The one rule that shapes every return type: nothing is invented. Every claim carries an EvidenceRef that resolves to a real quote or web finding on the report’s evidence list. When a method can’t ground something, it drops it or marks the result partial — it does not fill the gap with plausible text.

Constructing the client

class Metalworks:
    def __init__(
        self,
        *,
        chat: ChatModel | None = None,
        fast_chat: ChatModel | None = None,
        embeddings: EmbeddingProvider | None = None,
        store: Store | None = None,
        reader: CorpusReader | None = None,
        search: SearchProvider | None = None,
        comments: CommentSource | None = None,
        model: str | None = None,
        fast_model: str | None = None,
    ) -> None: ...
The common cases:
mw = Metalworks()                       # provider inferred from your env key
mw = Metalworks(model="anthropic/claude-opus-4-8")   # pin a provider/model
mw = Metalworks(model="openai/gpt-5", fast_model="openai/gpt-5-mini")  # cheap triage model
ArgumentWhat it does
modelThe main model, as "provider/model" or "provider:model". If omitted, resolved from the first env key present: ANTHROPIC_API_KEYOPENAI_API_KEYGOOGLE_API_KEY.
fast_modelA cheaper model for triage/filtering. Falls back to model when unset.
chat / fast_chat / embeddings / search / reader / commentsPass a fully-built object to swap any single layer (e.g. an OpenAI-compatible endpoint, a custom corpus). See Extending metalworks.
storeWhere runs/reports persist. Defaults to your project’s .metalworks/corpus.db if one exists, else in-memory. See Projects & memory.
Provider refs route by namespace: anthropic, openai, google/gemini are native; anything else (openrouter/..., meta-llama/...) routes through an OpenAI-compatible endpoint. To point at a local or custom endpoint, construct an OpenAIChatModel(base_url=..., api_key_env=...) and pass it as chat=.

Research

.research(...)

def research(
    self,
    question: str | ResearchBrief,
    *,
    subreddits: list[str] | None = None,
    time_window_months: int | None = None,
    per_sub_limit: int | None = None,
    max_findings: int = 10,
) -> Research: ...
Runs the demand pipeline and returns a frozen Research bundle.
ArgumentDefaultNotes
questionA plain sentence, or a fully-built ResearchBrief (from .plan()) for full control.
subredditsplanner picksNames without r/. Omit to let the planner choose.
time_window_months12How far back the corpus window reaches.
per_sub_limitpipeline defaultCap submissions pulled per subreddit.
max_findings10Max demand clusters to surface.
If you’re inside a project, the run is automatically persisted to .metalworks/runs/<report_id>/. Casual use (no project) leaves no footprint.
research = mw.research("a jitter-free focus supplement for developers",
                       subreddits=["Nootropics", "Supplements"])
report = research.demand
print(report.verdict)                         # one-line go / no-go (str | None)
for c in report.ranked_clusters:
    print(c.distinct_author_count, "people:", c.claim)
    for q in c.quotes:
        print("  ", q.source_url, q.text[:80])

.plan(prompt)

def plan(self, prompt: str) -> ResearchBrief: ...
Walks the planner end-to-end (taking the recommended answer at each decision) and returns a ResearchBrief you can inspect, edit, and pass straight back into .research(brief).

The Research bundle

research() returns a frozen Research. The demand report is on .demand; .evidence is the flat, resolvable evidence list every downstream method’s EvidenceRefs point at.
AttributeTypeNotes
.demandDemandReportThe report (see Data model).
.evidencelist[EvidenceRecord]The grounded evidence, surfaced for resolving refs.
.competitors / .positioning… | NoneReserved accessors; None today (forward-compatible).
Key fields you’ll read on .demand:
FieldTypeMeaning
verdictstr | NoneThe go/no-go summary line.
ranked_clusterslist[InsightCluster]The demand clusters, ranked by demand_score.
total_distinct_authorsintDistinct people across the corpus (the honest base rate).
price_findingPriceFinding | NonePrice band, if the corpus carried price signal.
segments / audience_profileInferred audience, when grounded.
web_findingslist[WebFinding]External findings, each with a source URL.
partial / caveatbool / str | NoneSet when the signal was too thin to be confident.
Each InsightCluster carries rank, claim, demand_score, distinct_author_count, mention_count, signal, and quotes (verbatim ResolvedCitations with text, source_url, source/source_name, author_hash, engagement).

The stage methods

Each method below runs on a finished research() bundle (or a bare DemandReport). They’re the Research → Design → Build → Launch → Grow arc. All read from the same report, so every output traces back to the same evidence.

Research stage

def positioning(self, research: Research | DemandReport) -> PositioningBrief: ...
def competitors(self, research: Research | DemandReport) -> CompetitorMap: ...
positioning returns a Dunford-style wedge + price hypothesis. competitors returns direct/adjacent/status-quo rivals, each gap backed by a real complaint. Both set partial=True with a caveat when there’s no defensible wedge / the named set couldn’t be grounded.
pos = mw.positioning(research)
print(pos.positioning_statement)
if pos.wedge:                      # None when no white-space cluster qualifies
    print(pos.wedge.unique_attribute)

comp = mw.competitors(research)
for rival in comp.competitors:
    for gap in rival.gaps:
        print(rival.name, "misses:", gap.claim)   # each gap has a resolvable EvidenceRef

Design stage

def surface(self, research, positioning: PositioningBrief) -> SurfaceRecommendation: ...
def ux(self, research, positioning: PositioningBrief, surface: SurfaceKind) -> UxSkeleton: ...
def site(self, research, positioning: PositioningBrief | None = None) -> MarketingSite: ...
def render_site(self, site: MarketingSite, research=None) -> str: ...
surface picks sdk/web/mobile/cli/… with a cited rubric; ux sketches 3–5 screens, each flagged validated (evidence-backed) or hypothesis. site builds marketing copy where every load-bearing line is a verbatim quote; render_site turns it into a self-contained index.html string.
surface = mw.surface(research, pos)
ux = mw.ux(research, pos, surface.chosen)
site = mw.site(research, pos)
open("index.html", "w").write(mw.render_site(site, research))

Build stage

def build_spec(self, research, positioning=None, surface: SurfaceKind = "web",
               *, stack: str = "empty") -> BuildSpec: ...
def scaffold(self, spec: BuildSpec, research, dest: Path, *, base: str = "empty") -> list[Path]: ...
build_spec maps demand to a feature list (each feature tied to ≥1 real quote; ungrounded ones are dropped). scaffold writes a cite-or-die build harness under dest and returns the paths written. metalworks writes the spec, not the product — your coding agent builds from the scaffold.
spec = mw.build_spec(research, pos, surface.chosen)
paths = mw.scaffold(spec, research, Path("./my-startup"))
scaffold raises ValueError if spec.report_id doesn’t match the research bundle.

Launch & Grow stages

def launch(self, research, positioning=None) -> list[LaunchAsset]: ...
def channel_plan(self, research, surfaces: list[str] | None = None) -> ChannelPlan: ...
def content_plan(self, research) -> ContentPlan: ...
launch drafts channel-native assets (Product Hunt / Show HN / X), each claim carrying a ClaimCitation with exact character spans — it never posts. channel_plan is a deterministic, human-executed checklist (every step is requires_human=True). content_plan is deterministic and zero-key: one page per demand cluster, with FAQ blocks and citation targets.
assets = mw.launch(research, pos)
plan = mw.channel_plan(research, surfaces=["product_hunt", "show_hn"])
content = mw.content_plan(research)        # no LLM call, no key needed

.deps

@property
def deps(self) -> ResearchDeps: ...
The resolved dependency container (chat, embeddings, corpus, reader…). The escape hatch for calling the raw stage functions yourself — e.g. build_positioning_brief(mw.deps, report) — without rebuilding the providers by hand.

.reddit — Reddit surfaces

Reads are zero-key; the rate limiter is shared across all calls on one client.
def search(self, query: str, *, subreddit: str | None = None, limit: int = 15) -> list[RedditPost]
def subreddit(self, name: str) -> SubredditIntel
def comments(self, post_url: str, *, limit: int = 10) -> list[RedditComment]
def rules(self, name: str) -> list[str]
def inbox(self, *, access_token: str, limit: int = 25) -> list[InboxItem]
def post(self, post_url: str, text: str, *, username: str) -> PostResult
posts = mw.reddit.search("focus supplement", subreddit="Nootropics", limit=10)
intel = mw.reddit.subreddit("Nootropics")          # subscribers, rules, top posts
rules = mw.reddit.rules("Nootropics")
comments = mw.reddit.comments(posts[0].url)
.inbox and .post are the authenticated surfaces. .post is gated: it runs the deterministic compliance check first and refuses on a block verdict (returning a failed PostResult), and every attempt — blocked or sent — is appended to ~/.metalworks/post-log.jsonl. It needs REDDIT_CLIENT_ID / REDDIT_CLIENT_SECRET and a previously connected account. See Reddit engagement.

.discovery — find threads, draft replies

def run(self, queries: list[str], *, subreddits: list[str] | None = None,
        max_opportunities: int = 30, context: DiscoveryContext | None = None) -> list[Opportunity]
def filter(self, post: RedditPost, *, context: DiscoveryContext | None = None) -> FilterDecision | None
def generate(self, post: RedditPost, *, persona: Persona | None = None,
             account_type: str = "expert", context: DiscoveryContext | None = None,
             subreddit_rules: list[str] | None = None) -> ReplyGenerationV2 | None
run is the full loop (search → filter → generate → compliance-gate) and returns draft Opportunity objects — it never posts. filter and generate are the building blocks if you want to drive the loop yourself.
from metalworks.contract import DiscoveryContext, Persona

ctx = DiscoveryContext(
    voice_guidelines=["be direct", "cite specifics"],
    personas={"founder": Persona(background="founder of a sleep-tech startup")},
)
opps = mw.discovery.run(["focus supplement", "nootropic stack"],
                        subreddits=["Nootropics"], context=ctx)
for o in opps:
    print(o.post.title)
    print(o.draft_reply)
    print(o.compliance.pass_)        # the compliance verdict on the draft
Persona.background must be authentic — fabricated personas and invented backstories are prohibited by the usage policy.

Exceptions

All inherit from MetalworksError, which carries an optional fix hint and docs_url. Import them from metalworks.errors.
ExceptionRaised whenFix
MissingExtraErrorA feature needs an optional dependencypip install "metalworks[<extra>]"
MissingKeyErrorA required API key isn’t setExport the env var it names
RateLimitedErrorAn upstream returns 429 after retriesBack off and retry
StructuredOutputErrorA model can’t match the required schemaRetry, simplify, or use a stronger model
GroundingUnavailableThe model can’t do grounded web searchUse a grounding-capable model or pass a SearchProvider
ReauthRequiredErrorA Reddit OAuth token expired/was revokedmetalworks reddit auth login
EmbeddingModelMismatchA cached index was built with a different embed modelRe-embed, or switch back to that model
StoreError / RedditErrorThe storage backend / Reddit API failedCheck the backend / credentials

See also