sharkmagic.docs
Framework integrations

Agent? Fetch sharkmagic.si/docs/llms.txt or this page as markdown

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. 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#

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), 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#

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#

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#

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, 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.

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; 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).
  • 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.