# Framework integrations (Python): LangChain, LangGraph, smolagents, Prefect

Run agent-written code and heavy jobs in Shark from the framework you already use. Each integration is a small
module of the Python SDK (`shark.integrations.*`) installed as an optional extra; `import shark` never needs any
framework.

**Status.** The SDK and these integrations use API v1 (`/v1/...`: files, fork, op replay). They were tested against
a local `sharkfs api` whose commands run with bash on the host instead of a microVM (no isolation, no network policy,
no VM timings), with the framework versions listed in [Tested against](#tested-against). They have not yet run
against a hosted endpoint, and API v1 is not yet confirmed on the measured bare-metal server. TypeScript adapters do
not exist yet.

## Install

```sh
pip install 'shark-sdk[langgraph]'      # or [langchain], [smolagents], [prefect]; several: [langgraph,prefect]
export SHARK_API_KEY=...   # an account key; never put it in code or in the agent's prompt. Leave SHARK_API unset (direct mode)
```

`shark-sdk` is a provisional name and the package is not on PyPI yet: install it as your invite says
([quickstart.md](quickstart.md#0-what-you-need)), with the extra for your framework.

## Which piece to use

| you have | use | the Space comes from |
|---|---|---|
| a LangChain agent or any tool-calling loop | `SharkExecTool` (+ `SharkReadFileTool`, `SharkWriteFileTool`) | `space=`, or per run from the config |
| a LangGraph graph with a checkpointer | `thread_space`, `branch_spaces`, and the tools above with `shark=` | one per `thread_id`; a fork per parallel branch |
| a smolagents `CodeAgent` | `SharkExecutor` (`executor=`) or `SharkCodeAgent(executor_type="shark")` | `space=`, or a new one per executor |
| a Prefect flow | `task_fork`, `task_op_id` | a fork of a base Space per task run |

## LangChain: one tool, bash or Python

```python
from shark import Shark
from shark.integrations.langchain import SharkExecTool

shark = Shark()
tool = SharkExecTool(space=shark.spaces.create(labels={"client": "a"}))
print(tool.invoke({"command": "mkdir -p data && echo hello > data/a.txt && ls data"}))
print(tool.invoke({"command": "print(open('data/a.txt').read().upper())", "language": "python"}))
# exit: 0
# stdout:
# HELLO
```

What the model sees is one tool, `shark_exec`, with `command`, `language` (`bash` or `python`) and `timeout_s`. It
gets back the exit code, stdout and stderr (their last 20,000 and 10,000 characters by default:
`max_output_chars`). A command that fails is a result (`exit: 1`), not an exception; a timeout comes back as
`exit: timeout`; an infrastructure error is raised as a `ToolException`, which LangChain hands to the model as text.

- **Exactly once.** When the tool is called with a model tool call (LangGraph's `ToolNode`, or
  `tool.invoke(tool_call)`), the command's `op_id` is derived from the tool-call id. If a graph resumes from a
  checkpoint and executes the same tool call again, Shark returns the stored result (`replayed: true`) instead of
  running the command a second time.
- **Files without a VM.** `SharkReadFileTool` and `SharkWriteFileTool` read and write the Space's files directly; no
  command runs. `shark_tools(space)` returns the three tools together.
- **Your code, not the model's.** `tool.upload("./local_dir", "in")` and `tool.download("out", "./results")` move whole
  directories (one commit each).
- Each call runs in a fresh machine: files persist to the next call; `cd`, exported variables and running processes do
  not. Use `cwd=` and `env=` on the tool for defaults.

## LangGraph: one Space per thread, one fork per branch

```python
from langgraph.types import Send
from shark.integrations.langchain import shark_tools
from shark.integrations.langgraph import thread_space, branch_spaces

tools = shark_tools(shark=shark)              # resolves the thread's Space at call time: use them in a ToolNode

def prepare(state, config):                   # a node
    thread_space(shark, config).files.write("data/leads.csv", state["csv"])
    return {}

def fan_out(state, config):                   # a conditional edge: N forks, N branches in parallel
    forks = branch_spaces(shark, config, len(state["strategies"]), step="dedupe")
    return [Send("branch", {"space_id": f.id, "strategy": s}) for f, s in zip(forks, state["strategies"])]
```

- `thread_space(shark, config)` is the Space of `configurable.thread_id`. It is created on first use and found again
  by label on every later run of that thread, from any worker; two workers racing on a new thread get one Space. For
  one Space per client instead of per thread, call `space_for(shark, "client:<id>")` from `shark.integrations`.
- `branch_spaces(...)` forks that Space: O(1), the files are shared and nothing is copied. Processes and service memory
  are not forked, and a fork starts with no op history. The fork request is idempotent per graph task, so a node
  re-executed after a resume gets the same forks; a new run of the thread gets new ones.
- Inside a branch, run commands on the fork (`shark.spaces.get(space_id).run(...)`) or hand it to the tools with
  `config={"configurable": {"shark_space_id": space_id}}`. Keep the winner's files (read them from the fork and write
  them into the thread's Space), then delete the forks.
- The tools pick the Space in this order: `space=` if given; `configurable.shark_space_id`; the thread's Space.

A complete graph (prepare, fan out over three forks, pick the best, clean up) and a model → `ToolNode` loop are in
`sdk/python/examples/langgraph_threads_and_forks.py`.

## smolagents: Shark as the CodeAgent's executor

```python
from smolagents import CodeAgent
from shark.integrations.smolagents import SharkExecutor, SharkCodeAgent

space = shark.spaces.create(labels={"agent": "leads"})
agent = CodeAgent(tools=[], model=model, additional_authorized_imports=["requests"],
                  executor=SharkExecutor(["requests"], space=space))
agent.run("...")

# executor_type-style, same thing:
agent = SharkCodeAgent(tools=[], model=model, executor_type="shark", executor_kwargs={"space": space})
```

`SharkExecutor` implements smolagents' `RemotePythonExecutor` interface, the one its E2B, Docker, Modal and Blaxel
executors implement (checked against smolagents 1.26.0). smolagents' `executor_type` only accepts its own list of
names, so a Shark executor goes in through `executor=`, or through `SharkCodeAgent`, a `CodeAgent` subclass that
also accepts `executor_type="shark"`.

**State works differently from a notebook kernel.** Each code cell runs in a fresh microVM, so nothing stays in memory
between cells. The executor keeps Python state in the Space's files (`.shark-smolagents/`): the tools, and each
cell's top-level imports, functions and classes, are replayed before the next cell; other variables are pickled after
each cell. A value that cannot be pickled (an open file or socket, a generator, a lambda, a live browser) is dropped,
and the cell's logs name it so the model can recreate it. Files the code writes persist as usual.

- Packages: modules in `additional_imports` that the Space cannot import yet are installed once with `pip --target`
  into the Space and reused by every later cell and every fork. `packages=[...]` gives exact pip requirements;
  `pip_args=[...]` adds options such as a private index.
- Each cell is one command: a lease costs 44 ms on the hosted hardware ([numbers.md](numbers.md), AX41,
  2026-11-05), plus the replay of the cell's state.
- `timeout=` is per cell (default 300 s); a cell that runs out of time comes back to the agent as an error it can react
  to. `cleanup()` deletes the Space only if the executor created it.
- Not supported: smolagents managed agents with a remote executor (smolagents' own restriction).

## Prefect: burst instead of queue

Prefect stays the control plane (schedules, retries, the UI); the work runs in Shark. Prepare a base Space once (code,
dependencies), then give every task run its own fork, so N heavy jobs run side by side instead of waiting in a queue for
one large worker.

```python
from prefect import flow, task, unmapped
from shark.integrations.prefect import task_fork, task_op_id

@task(retries=2)
def process(base_id: str, url: str) -> str:
    with task_fork(shark.spaces.get(base_id)) as sp:            # this task run's own fork, deleted afterwards
        r = sp.start(f"python3 job.py '{url}'", timeout=3600, op_id=task_op_id("job")).wait()
        assert r.exit == 0, r.stderr[-2000:]
        return sp.files.read_text("out/summary.json")

@flow
def burst(base_id: str, urls: list[str]):
    return process.map(unmapped(base_id), urls).result()
```

The fork's idempotency key is the task run id plus its attempt number: a request the SDK retries returns the same
fork, and a Prefect retry starts from a fresh fork of the base. No Prefect server is needed to run this (Prefect's
ephemeral local mode works); with `PREFECT_API_URL` set the runs show up in your server or Prefect Cloud. Full flow:
`sdk/python/examples/prefect_burst.py`.

## Heavy jobs: one command does the whole job

A job like "download a 500 MB archive, unzip it, parse every document" belongs in **one** command (one script), not
spread over several tool calls:

- Download and unzip into the command's temporary directory (`$TMPDIR`, or Python's `tempfile`), not into the Space.
  Write only what you need afterwards (a summary, the extracted fields) into the Space: everything a command leaves in
  the Space's tree is saved when it ends.
- Set `timeout` to the whole job, and mind the server's caps: a synchronous `space.run()` accepts up to 15 min by
  default, an async op (`space.start(...)` then `.wait()`, as in the Prefect example) up to 2 h. A larger `timeout`
  is refused with `400 invalid_request` (its details give `max_s`), so use an async op for longer jobs. The tools and the smolagents executor use synchronous calls.
- A headless browser works if it starts and exits inside the command: install it into the Space once (for example
  Playwright with its browser in a directory of the Space), then each command launches it, does its steps and closes
  it. The browser does not stay open between commands.
- In parallel: fork once per job (Prefect above, or `shark.integrations.forked(space, key)` anywhere).

Not measured yet: a 500 MB download, unzip and parse on a Shark host, alone or many in parallel, and a headless browser
inside a command. The local tests ran this pattern with small archives (≈ 1 MB each) on the stand-in described in
**Status**.

## Exactly once and retries

| integration | op_id / idempotency key | what a retry does |
|---|---|---|
| `SharkExecTool` called with a tool call | `lc:<tool_call_id>` | returns the stored result, `replayed: true` |
| `SharkExecTool` called with plain input | generated per call | the SDK's own retries reuse it; a new call runs again |
| `thread_space` / `space_for` | create: `space:<key>` | the same Space (the label finds it after the key's 24 h) |
| `branch_spaces` | fork: thread + graph task + step | the same forks while the key lives (24 h) |
| `task_fork`, `task_op_id` | task run id + attempt | SDK retries: same fork and op; Prefect retry: new fork |
| `SharkExecutor` cells | generated per cell | the SDK's own retries reuse it |

A reused op_id with a different command is refused (`409`), never silently replayed. op_ids are per Space.

## Not supported yet

- **GPUs.**
- **Long-lived processes across commands**: a dev server, a watcher, a Jupyter kernel. Each command's processes end
  with it. (A node may keep a command's background process running as a job of the Space: hosted `shark code` does,
  see [concepts.md](concepts.md#background-processes); the API/SDK integrations here do not rely on it.)
- **Persistent browser sessions**: a browser kept open between steps, with its tabs and logins. A browser that runs
  inside one command works; one that lives across commands is a design, not a product.
- **Preview URLs from the SDK tools**: live on the hosted node through the API and the CLI (`shark expose`,
  `shark open`), but the SDKs and these integrations have no method for them yet
  ([concepts.md](concepts.md#exposed-processes-and-preview-urls)).
- Streaming a command's output to the model as it runs (the tools return when the command ends; the SDK's
  `space.stream()` streams to your own code).
- TypeScript adapters (LangChain.js and others); smolagents managed agents with remote execution; Windows and macOS
  guests.

## Tested against

`sdk/python/tests/test_integrations.py`, 16 tests, all passing on 2026-11-05 with Python 3.13, langchain-core 1.7.6,
langgraph 1.2.20, smolagents 1.26.0, prefect 3.9.1 (pinned in `sdk/python/tests/integrations-constraints.txt`),
against a local `sharkfs api` with the stand-in for the microVM described in **Status**. Model turns are scripted: no
model was called. On that stand-in a command's time limit is enforced by the API about 30 s after `timeout_s`, not by
the microVM, so the timeout tests only check that a timeout reaches the model as a result. What the tests do not cover:
a hosted endpoint, real microVMs, network policy, large downloads, a real model.