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
38 changes: 34 additions & 4 deletions .claude/skills/coact-publish/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ description: >-
connector / plugin / MCP server / .mcpb / Desktop Extension / "integration"
from existing Python code or a skill — e.g. "make an mcpb", "package these
functions for Claude", "turn this into a Claude extension/connector", "publish
a local MCP server", "wrap my tools as a Claude Desktop extension". For REMOTE
claude.ai connectors (HTTPS + OAuth) this is the wrong target — that surface is
not built yet (see Limitations).
a local MCP server", "wrap my tools as a Claude Desktop extension". Also use to
draft an integration from a natural-language description ("describe an
integration", "I want a Claude connector that can…") via `coact describe`. For
REMOTE claude.ai connectors (HTTPS + OAuth) this is the wrong target — that
surface is not built yet (see Limitations).
metadata:
version: 0.1.0
version: 0.2.0
---

# coact publish — Python capability → Claude integration
Expand Down Expand Up @@ -48,6 +50,34 @@ Sources accepted (mix freely): `module:function` refs, a skill directory /
`SKILL.md` (its `coact: mcp:` block supplies the refs), or — from Python — live
callables and a prebuilt `IntegrationSpec`.

## From a natural-language description (opt-in LLM)

To go from an English description to a *draft* integration (proposed tools with
inferred input schemas), use `coact describe` — the **only** LLM-using path here
(the `module:function` → `.mcpb` path stays LLM-free). Generation routes through
`aix` (multi-provider) by default; `--llm` picks a model.

```bash
coact describe "a connector that looks up the weather for a city and converts currencies"
# → renders a draft IntegrationSpec: proposed tools, each marked "proposed (no handler)"
```

```python
from coact import integration_spec_from_description, publish

spec = integration_spec_from_description(
"expose os.path.basename and os.path.dirname as tools", name="paths"
)
# tools the description bound to existing code become runnable refs:
publish(spec, name="paths", dest="dist") # works iff spec.runnable_refs()
```

The draft is a **design artifact**: tools are *proposed* (no importable handler)
unless the description named existing code. **Bind** each proposed tool to a real
`module:function` handler (write the code, or point at existing functions) before
`publish` can build a runnable `.mcpb` — coact writes the design, you own the
code. Needs `coact[nl]` (`oa`, `aix`), imported lazily only on this path.

Python API:

```python
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ coact inventory . # skills + agents + MC
coact back .claude/agents/ux-analyst.md # lossy agent → skill stub
coact scaffold .claude/agents/a.md .claude/agents/b.md # a starter fleet shim (you own it)
coact publish mypkg.tools:summarize --name my-tools --dry-run # → a Claude .mcpb (preview)
coact describe "a tool that looks up the weather for a city" # NL → a draft IntegrationSpec
```

## Publish — ship a capability to a chatbot host
Expand All @@ -109,6 +110,27 @@ planned targets on the same open-closed registry. Background:
[`misc/docs/CHATBOT_INTEGRATION_LANDSCAPE.md`](misc/docs/CHATBOT_INTEGRATION_LANDSCAPE.md).
Install: `pip install coact[mcpb]`.

There are two ways to get an `IntegrationSpec`. The **mechanical** ingress above
(refs / callables / skills) uses **no LLM**. The **opt-in** ingress refines a
natural-language description into a *draft* spec — proposed tools with inferred
input schemas — routing generation through [`aix`](https://github.com/thorwhalen/aix)
(multi-provider) via [`oa`](https://github.com/thorwhalen/oa):

```python
from coact import integration_spec_from_description

# This routes through aix/oa — it makes a real LLM call (needs a configured
# provider). Inject `llm=<callable>` to run it offline (e.g. in tests).
spec = integration_spec_from_description("expose os.path.basename as a tool")
print(spec.render()) # a DRAFT: a tool becomes a runnable ref only if the model
# binds it to module:function code — else it stays proposed
```

The draft is a **design artifact**: tools without a `module:function` handler are
*proposed* (won't run until you bind them to real code). The LLM touches **only**
this path — the code → `.mcpb` path stays LLM-free (`DECISIONS.md` D10/D18).
Install: `pip install coact[nl]`.

## The model in one minute

A `SKILL.md` is *procedural knowledge injected into the caller's turn*; a subagent
Expand Down
5 changes: 4 additions & 1 deletion coact/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@
RunnableCrewAIAgent,
realize_crewai,
) # registers 'crewai'
from coact.integration import IntegrationSpec, integration_spec_from
from coact.integration import IntegrationSpec, ToolSpec, integration_spec_from
from coact.nl_ingress import integration_spec_from_description
from coact.publish import PublishResult, publish, publish_targets
from coact.publish_mcpb import publish_mcpb # registers 'claude-local-mcpb'
from coact.scaffold import scaffold_fleet
Expand Down Expand Up @@ -125,7 +126,9 @@ def _resolve_version() -> str:
"realization_backends",
# PUBLISH (ship a capability to a chatbot host; Claude-local .mcpb first; D17)
"IntegrationSpec",
"ToolSpec",
"integration_spec_from",
"integration_spec_from_description", # opt-in LLM NL ingress (aix-backed; D18)
"publish",
"publish_targets",
"PublishResult",
Expand Down
40 changes: 38 additions & 2 deletions coact/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
Mirrors ``skill``'s dispatch-to-interface pattern (CLI wrappers call the same
core functions and format for the terminal), so the two packages feel like one
toolkit. The verbs are ``plan``, ``complete``, ``emit``, ``realize``, ``diff``,
``estimate``, ``inventory``, ``back``, ``scaffold``, and ``publish``. Usage::
``estimate``, ``inventory``, ``back``, ``scaffold``, ``publish``, and
``describe``. Usage::

python -m coact plan .claude/skills/ux-analyst
python -m coact complete .claude/skills/ux-analyst --dest .claude/agents
Expand All @@ -17,6 +18,7 @@
python -m coact back .claude/agents/ux-analyst.md
python -m coact scaffold .claude/agents/a.md .claude/agents/b.md
python -m coact publish my.module:my_func --dry-run
python -m coact describe "a tool that looks up the weather for a city"
"""

from __future__ import annotations
Expand Down Expand Up @@ -158,10 +160,44 @@ def publish(
return res.render()


def describe(
description: str,
*,
name: str | None = None,
llm: str | None = None,
author: str | None = None,
) -> str:
"""Refine an NL description into a draft IntegrationSpec (opt-in LLM, aix-backed).

The result is a *design draft*: proposed tools (with inferred input schemas).
Bind each tool to a ``module:function`` handler — or include such refs in the
description — then ``coact publish`` it. ``--llm`` is a model name (e.g.
``gpt-4o``); the backend defaults to ``aix`` (multi-provider).
"""
from coact.nl_ingress import integration_spec_from_description

spec = integration_spec_from_description(
description, llm=llm, name=name, author=author
)
return spec.render()


def main() -> None:
"""Dispatch the coact CLI."""
argh.dispatch_commands(
[plan, complete, emit, realize, diff, estimate, inventory, back, scaffold, publish]
[
plan,
complete,
emit,
realize,
diff,
estimate,
inventory,
back,
scaffold,
publish,
describe,
]
)


Expand Down
101 changes: 95 additions & 6 deletions coact/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,55 @@
]


@dataclass
class ToolSpec:
"""A richer, target-neutral description of one tool in an :class:`IntegrationSpec`.

The *mechanical* ingress represents tools as bare ``'module:function'`` ref
strings (``IntegrationSpec.tools``). The *NL* ingress (:mod:`coact.nl_ingress`)
and the landscape-doc §9.1 model need more — a name, a description, an input
JSON Schema, and an **optional** handler ref:

- A ToolSpec **with** a ``handler`` is *bound* (runnable): its ref joins the
spec's runnable set and the published server can import and call it.
- A ToolSpec **without** a handler is a *proposed* tool — a design draft to
bind to real code (or supply a ref for) before it can run.

>>> ToolSpec(name='get_weather', handler='wx.api:current').is_bound()
True
>>> ToolSpec(name='get_weather').is_bound()
False
"""

name: str
description: str = ""
input_schema: Optional[dict] = None
handler: Optional[str] = None # 'module:function' ref, or None if unbound

def is_bound(self) -> bool:
"""True when a ``module:function`` handler backs this tool (so it can run)."""
return bool(self.handler)


@dataclass
class IntegrationSpec:
"""Target-neutral description of an integration to publish.

The connectivity core maps onto MCP's three primitives. Only ``tools`` is
consumed by the local-``.mcpb`` target today; ``resources``/``prompts`` and
the ``auth``/``deployment`` hints are declared now (open-closed) for the
The connectivity core maps onto MCP's three primitives. Tools come in two
shapes that coexist: bare ``'module:function'`` refs in ``tools`` (the
mechanical/code ingress) and richer :class:`ToolSpec` descriptors in
``tool_specs`` (the NL ingress, landscape-doc §9.1). ``resources``/``prompts``
and the ``auth``/``deployment`` hints are declared now (open-closed) for the
remote connector and other targets to come.

>>> spec = IntegrationSpec(name='paths', tools=['os.path:basename'])
>>> spec.name, spec.tools, spec.deployment
('paths', ['os.path:basename'], 'local-stdio')
>>> IntegrationSpec(name='empty').is_empty()
True
>>> draft = IntegrationSpec(name='wx', tool_specs=[ToolSpec(name='get')])
>>> draft.is_empty(), draft.runnable_refs()
(False, [])
"""

name: str
Expand All @@ -54,15 +89,57 @@ class IntegrationSpec:
tools: list[str] = field(default_factory=list) # 'module:function' refs (MCP tools)
resources: list[str] = field(default_factory=list) # reserved (MCP resources)
prompts: list[str] = field(default_factory=list) # reserved (MCP prompts)
tool_specs: list[ToolSpec] = field(default_factory=list) # richer tool descriptors
instructions: Optional[str] = None # reserved (SKILL.md procedural knowledge)
auth: str = "none" # 'none' | 'env' | 'oauth2.1' (reserved for remote targets)
deployment: str = "local-stdio" # 'local-stdio' | 'remote-http' (reserved)
author: Optional[str] = None
source: Optional[str] = None # provenance: the skill/module it came from

def is_empty(self) -> bool:
"""True when there is no connectivity to publish (no tools/resources/prompts)."""
return not (self.tools or self.resources or self.prompts)
"""True when there is nothing to publish (no tools/tool_specs/resources/prompts)."""
return not (self.tools or self.tool_specs or self.resources or self.prompts)

def runnable_refs(self) -> list[str]:
"""The importable ``'module:function'`` refs that back *runnable* tools.

Bare refs in ``tools`` plus the ``handler`` of every *bound* ToolSpec,
de-duplicated preserving order. A draft whose tools are all *proposed*
(unbound) returns ``[]`` — it has nothing a server can actually run yet.
"""
refs = list(self.tools)
for ts in self.tool_specs:
if ts.handler and ts.handler not in refs:
refs.append(ts.handler)
return refs

def render(self) -> str:
"""A terminal-friendly summary of the (possibly draft) integration."""
lines = [f"IntegrationSpec: {self.name} (v{self.version})"]
if self.description:
lines.append(f" {self.description}")
lines.append(f" deployment: {self.deployment} auth: {self.auth}")
runnable = set(self.runnable_refs())
if self.tools or self.tool_specs:
lines.append(" tools:")
for ref in self.tools:
lines.append(f" - {ref} [ref]")
for ts in self.tool_specs:
tag = f"bound -> {ts.handler}" if ts.is_bound() else "proposed (no handler)"
lines.append(f" - {ts.name} [{tag}]")
if ts.description:
lines.append(f" {ts.description}")
if self.resources:
lines.append(" resources: " + ", ".join(self.resources))
if self.prompts:
lines.append(" prompts: " + ", ".join(self.prompts))
if self.tool_specs and not runnable:
lines.append(
" NOTE: design draft — no tool is bound to importable code. Bind "
"each tool to a 'module:function' handler (or supply refs) before "
"building a runnable .mcpb."
)
return "\n".join(lines)


def integration_spec_from(
Expand Down Expand Up @@ -90,14 +167,23 @@ def integration_spec_from(
return source

refs: list[str] = []
spec_tool_specs: list[ToolSpec] = []
spec_resources: list[str] = []
spec_prompts: list[str] = []
derived_name: Optional[str] = name
src_label: Optional[str] = None
unrecognized: list[str] = []
items = list(source) if isinstance(source, (list, tuple)) else [source]

for item in items:
if isinstance(item, IntegrationSpec):
# Carry the spec's richer connectivity, not just its bare refs — else a
# draft from `integration_spec_from_description` (whose runnable tools
# live in tool_specs[].handler) would silently lose every handler.
refs.extend(item.tools)
spec_tool_specs.extend(item.tool_specs)
spec_resources.extend(item.resources)
spec_prompts.extend(item.prompts)
derived_name = derived_name or item.name
elif _is_skill_obj(item): # before callable(): a Skill may define __call__
sk_refs, sk_name = _refs_and_name_from_skill(item)
Expand All @@ -123,7 +209,7 @@ def integration_spec_from(
+ ". Expected a 'module:function' ref (note the colon), an existing "
"skill directory / SKILL.md, or a live callable."
)
if not refs:
if not (refs or spec_tool_specs or spec_resources or spec_prompts):
raise ValueError(
"No tools found to publish. Provide 'module:function' refs, live "
"callables, or a skill carrying a `coact: mcp:` block (module + functions)."
Expand All @@ -134,6 +220,9 @@ def integration_spec_from(
description=description,
version=version,
tools=refs,
resources=spec_resources,
prompts=spec_prompts,
tool_specs=spec_tool_specs,
author=author,
source=src_label,
)
Expand Down
Loading
Loading